postrunner 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 872ed3c3ac1e84cd92bd1bde445738e1a37b0654
4
- data.tar.gz: 42ca82b63a31d004b6dd5d9feb237dc5ddf8013b
3
+ metadata.gz: 19aaff0f7f7586b68eb837bd1679ce7e31b9cfe7
4
+ data.tar.gz: a25b3ceb7a05e129edc6560d73ccb65a1ebe0e16
5
5
  SHA512:
6
- metadata.gz: dcf8664d4e28f88e644e71094e6e6d94dbafd01dd837b73bfe5c3c26c7a40f84d865bb407aeafc2dbc816a61df6ddb5c4d92993d9bd6338892831c4602188df0
7
- data.tar.gz: fc9dc455c07fd593343b25a58f830170db86138d219c61303a22cbe097f8aa68d484c6f6d84bb4b3fa1db1b1e8a91ff880c051fa02b8ed4e89bb9359acf37535
6
+ metadata.gz: e0abfc8cef81ebf908bbcedb7443eb2481537c0f4156502aa65e6730c2b58607117a0c0d58886907b042c049e38ec8043bc3c632e4fc2b3e2a965409a81e7962
7
+ data.tar.gz: 773ba376b663e6b8aaddbbbb3e84e968b9b57d1d5c5cb0b39d5e809725268afdcf9a667b5647f1e325bdce399198f58d575d1884725c71bac4f27bc240a6e57f
data/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  PostRunner is an application to manage FIT files such as those
4
4
  produced by Garmin products like the Forerunner 620 (FR620). It allows you to
5
- import the files from the device and inspect them.
5
+ import the files from the device and inspect them. It can also update
6
+ satellite orbit prediction data on the device to speed-up fix times.
6
7
 
7
8
  ## Installation
8
9
 
@@ -80,6 +81,22 @@ This will provide you with a lot more information contained in the FIT
80
81
  files that is not available through Garmin Connect or most other
81
82
  tools.
82
83
 
84
+ When you upload your FIT data to the Garmin Connect site using WiFi or
85
+ Garmin Software, your device will be updated with 7 days worth of
86
+ Extended Prediction Orbit (EPO) data. The GPS receiver in your device
87
+ can use this data to acquire GPS locks much faster during the next 7
88
+ days. To fetch the current set of EPO data, just use the following
89
+ command while you have your device mounted via USB.
90
+
91
+ ```
92
+ $ postrunner update-gps
93
+ ```
94
+
95
+ This was tested on the FR620 and will probably also work on the FR220.
96
+ Other devices may work, but you use this at your own risk. This
97
+ feature will download a file called EPO.BIN and copy it to
98
+ GARMIN/REMOTESW/EPO.BIN.
99
+
83
100
  ### Viewing FIT file data in your web browser
84
101
 
85
102
  You can also view the full details of your activity in your browser.
@@ -76,9 +76,9 @@ module PostRunner
76
76
  # files. This method is idempotent and can be called even when directories
77
77
  # exist already.
78
78
  def create_directories
79
- create_directory(@db_dir, 'data')
80
- create_directory(@fit_dir, 'fit')
81
- create_directory(@cfg[:html_dir], 'html')
79
+ @cfg.create_directory(@db_dir, 'data')
80
+ @cfg.create_directory(@fit_dir, 'fit')
81
+ @cfg.create_directory(@cfg[:html_dir], 'html')
82
82
 
83
83
  @auxilliary_dirs.each do |dir|
84
84
  create_auxdir(dir)
@@ -168,6 +168,12 @@ module PostRunner
168
168
 
169
169
  def set(activity, attribute, value)
170
170
  activity.set(attribute, value)
171
+ if %w( norecord type ).include?(attribute)
172
+ # If we have changed a norecord setting or an activity type, we need
173
+ # to regenerate all reports and re-collect the record list since we
174
+ # don't know which Activity needs to replace the changed one.
175
+ check
176
+ end
171
177
  sync
172
178
  end
173
179
 
@@ -344,17 +350,6 @@ module PostRunner
344
350
  ActivityListView.new(self).update_index_pages
345
351
  end
346
352
 
347
- def create_directory(dir, name)
348
- return if Dir.exists?(dir)
349
-
350
- Log.info "Creating #{name} directory #{dir}"
351
- begin
352
- Dir.mkdir(dir)
353
- rescue StandardError
354
- Log.fatal "Cannot create #{name} directory #{dir}: #{$!}"
355
- end
356
- end
357
-
358
353
  def create_auxdir(dir)
359
354
  # This file should be in lib/postrunner. The 'misc' directory should be
360
355
  # found in '../../misc'.
@@ -14,6 +14,8 @@ require 'fit4ruby'
14
14
 
15
15
  require 'postrunner/ActivitySummary'
16
16
  require 'postrunner/ActivityView'
17
+ require 'postrunner/Schema'
18
+ require 'postrunner/QueryResult'
17
19
 
18
20
  module PostRunner
19
21
 
@@ -26,7 +28,20 @@ module PostRunner
26
28
  @@CachedActivityValues = %w( sport sub_sport timestamp total_distance
27
29
  total_timer_time avg_speed )
28
30
  # We also store some additional information in the archive index.
29
- @@CachedAttributes = @@CachedActivityValues + %w( fit_file name )
31
+ @@CachedAttributes = @@CachedActivityValues + %w( fit_file name norecord )
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
+ }
30
45
 
31
46
  ActivityTypes = {
32
47
  'generic' => 'Generic',
@@ -138,6 +153,8 @@ module PostRunner
138
153
  else
139
154
  if @@CachedActivityValues.include?(name_without_at)
140
155
  @unset_variables << name_without_at
156
+ elsif name_without_at == 'norecord'
157
+ @norecord = false
141
158
  else
142
159
  Log.fatal "Don't know how to initialize the instance variable " +
143
160
  "#{name_without_at}."
@@ -159,6 +176,25 @@ module PostRunner
159
176
  end
160
177
  end
161
178
 
179
+ def query(key)
180
+ unless @@Schemata.include?(key)
181
+ raise ArgumentError, "Unknown key '#{key}' requested in query"
182
+ end
183
+
184
+ schema = @@Schemata[key]
185
+
186
+ if schema.func
187
+ value = send(schema.func)
188
+ else
189
+ unless instance_variable_defined?(key)
190
+ raise ArgumentError, "Don't know how to query '#{key}'"
191
+ end
192
+ value = instance_variable_get(key)
193
+ end
194
+
195
+ QueryResult.new(value, schema)
196
+ end
197
+
162
198
  def show
163
199
  generate_html_view #unless File.exists?(@html_file)
164
200
 
@@ -167,7 +203,7 @@ module PostRunner
167
203
 
168
204
  def summary
169
205
  @fit_activity = load_fit_file unless @fit_activity
170
- puts ActivitySummary.new(@fit_activity, @db.cfg[:unit_system],
206
+ puts ActivitySummary.new(self, @db.cfg[:unit_system],
171
207
  { :name => @name,
172
208
  :type => activity_type,
173
209
  :sub_type => activity_sub_type }).to_s
@@ -199,6 +235,11 @@ module PostRunner
199
235
  ActivitySubTypes.values.join(', ')
200
236
  end
201
237
  @sub_sport = ActivitySubTypes.invert[value]
238
+ when 'norecord'
239
+ unless %w( true false).include?(value)
240
+ Log.fatal "norecord must either be 'true' or 'false'"
241
+ end
242
+ @norecord = value == 'true'
202
243
  else
203
244
  Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " +
204
245
  'name, type or subtype'
@@ -207,6 +248,10 @@ module PostRunner
207
248
  end
208
249
 
209
250
  def register_records
251
+ # If we have the @norecord flag set, we ignore this Activity for the
252
+ # record collection.
253
+ return if @norecord
254
+
210
255
  distance_record = 0.0
211
256
  distance_record_sport = nil
212
257
  # Array with popular distances (in meters) in ascending order.
@@ -295,7 +340,7 @@ module PostRunner
295
340
 
296
341
  # Store the found records
297
342
  start_time = @fit_activity.sessions[0].timestamp
298
- if @distance_record_sport
343
+ if distance_record_sport
299
344
  @db.records.register_result(self, distance_record_sport,
300
345
  distance_record, nil, start_time)
301
346
  end
@@ -92,8 +92,8 @@ module PostRunner
92
92
  t.row([
93
93
  i += 1,
94
94
  ActivityLink.new(a, true),
95
- a.activity_type,
96
- a.timestamp.strftime("%a, %Y %b %d %H:%M"),
95
+ a.query('type'),
96
+ a.query('long_date'),
97
97
  local_value(a.total_distance, 'm', '%.2f',
98
98
  { :metric => 'km', :statute => 'mi' }),
99
99
  secsToHMS(a.total_timer_time),
@@ -21,8 +21,9 @@ module PostRunner
21
21
 
22
22
  include Fit4Ruby::Converters
23
23
 
24
- def initialize(fit_activity, unit_system, custom_fields)
25
- @fit_activity = fit_activity
24
+ def initialize(activity, unit_system, custom_fields)
25
+ @activity = activity
26
+ @fit_activity = activity.fit_activity
26
27
  @name = custom_fields[:name]
27
28
  @type = custom_fields[:type]
28
29
  @sub_type = custom_fields[:sub_type]
@@ -54,9 +55,10 @@ module PostRunner
54
55
  local_value(session, 'total_distance', '%.2f %s',
55
56
  { :metric => 'km', :statute => 'mi'}) ])
56
57
  t.row([ 'Time:', secsToHMS(session.total_timer_time) ])
57
- if session.sport == 'running'
58
+ if @activity.sport == 'running' || @activity.sport == 'multisport'
58
59
  t.row([ 'Avg. Pace:', pace(session, 'avg_speed') ])
59
- else
60
+ end
61
+ if @activity.sport != 'running'
60
62
  t.row([ 'Avg. Speed:',
61
63
  local_value(session, 'avg_speed', '%.1f %s',
62
64
  { :metric => 'km/h', :statute => 'mph' }) ])
@@ -86,6 +88,11 @@ module PostRunner
86
88
  t.row([ 'Avg. Stride Length:',
87
89
  local_value(session, 'avg_stride_length', '%.2f %s',
88
90
  { :metric => 'm', :statute => 'ft' }) ])
91
+ rec_hr = @fit_activity.recovery_hr
92
+ end_hr = @fit_activity.ending_hr
93
+ t.row([ 'Recovery HR:',
94
+ rec_hr && end_hr ?
95
+ "#{rec_hr} bpm [#{end_hr - rec_hr} bpm]" : '-' ])
89
96
  rec_time = @fit_activity.recovery_time
90
97
  t.row([ 'Recovery Time:', rec_time ? secsToHMS(rec_time * 60) : '-' ])
91
98
  vo2max = @fit_activity.vo2max
@@ -100,7 +107,7 @@ module PostRunner
100
107
  t = FlexiTable.new
101
108
  t.head
102
109
  t.row([ 'Lap', 'Duration', 'Distance',
103
- session.sport == 'running' ? 'Avg. Pace' : 'Avg. Speed',
110
+ @activity.sport == 'running' ? 'Avg. Pace' : 'Avg. Speed',
104
111
  'Stride', 'Cadence', 'Avg. HR', 'Max. HR' ])
105
112
  t.set_column_attributes(Array.new(8, { :halign => :right }))
106
113
  t.body
@@ -109,7 +116,7 @@ module PostRunner
109
116
  t.cell(secsToHMS(lap.total_timer_time))
110
117
  t.cell(local_value(lap, 'total_distance', '%.2f',
111
118
  { :metric => 'km', :statute => 'mi' }))
112
- if session.sport == 'running'
119
+ if @activity.sport == 'running'
113
120
  t.cell(pace(lap, 'avg_speed', false))
114
121
  else
115
122
  t.cell(local_value(lap, 'avg_speed', '%.1f',
@@ -61,7 +61,7 @@ module PostRunner
61
61
  # The main area with the 2 column layout.
62
62
  doc.div({ :class => 'main' }) {
63
63
  doc.div({ :class => 'left_col' }) {
64
- ActivitySummary.new(@activity.fit_activity, @unit_system,
64
+ ActivitySummary.new(@activity, @unit_system,
65
65
  { :name => @activity.name,
66
66
  :type => @activity.activity_type,
67
67
  :sub_type => @activity.activity_sub_type
@@ -15,7 +15,7 @@ module PostRunner
15
15
 
16
16
  def initialize(activity, unit_system)
17
17
  @activity = activity
18
- @sport = activity.fit_activity.sessions[0].sport
18
+ @sport = activity.sport
19
19
  @unit_system = unit_system
20
20
  @empty_charts = {}
21
21
  end
@@ -33,9 +33,10 @@ module PostRunner
33
33
  }
34
34
 
35
35
  doc.script(java_script)
36
- if @sport == 'running'
36
+ if @sport == 'running' || @sport == 'multisport'
37
37
  chart_div(doc, 'pace', "Pace (#{select_unit('min/km')})")
38
- else
38
+ end
39
+ if @sport != 'running'
39
40
  chart_div(doc, 'speed', "Speed (#{select_unit('km/h')})")
40
41
  end
41
42
  chart_div(doc, 'altitude', "Elevation (#{select_unit('m')})")
@@ -44,6 +45,7 @@ module PostRunner
44
45
  chart_div(doc, 'vertical_oscillation',
45
46
  "Vertical Oscillation (#{select_unit('cm')})")
46
47
  chart_div(doc, 'stance_time', 'Ground Contact Time (ms)')
48
+ chart_div(doc, 'temperature', 'Temperature (°C)')
47
49
  end
48
50
 
49
51
  private
@@ -94,9 +96,10 @@ EOT
94
96
  s = "$(function() {\n"
95
97
 
96
98
  s << tooltip_div
97
- if @sport == 'running'
99
+ if @sport == 'running' || @sport == 'multisport'
98
100
  s << line_graph('pace', 'Pace', 'min/km', '#0A7BEE' )
99
- else
101
+ end
102
+ if @sport != 'running'
100
103
  s << line_graph('speed', 'Speed', 'km/h', '#0A7BEE' )
101
104
  end
102
105
  s << line_graph('altitude', 'Elevation', 'm', '#5AAA44')
@@ -119,6 +122,7 @@ EOT
119
122
  [ '#A0D488', 273 ],
120
123
  [ '#F79666', 305 ],
121
124
  [ '#EE3F2D', nil ] ])
125
+ s << line_graph('temperature', 'Temperature', 'C', '#444444')
122
126
 
123
127
  s << "\n});\n"
124
128
 
@@ -161,6 +165,9 @@ EOT
161
165
  min_value = nil
162
166
  @activity.fit_activity.records.each do |r|
163
167
  value = r.get_as(field, select_unit(unit))
168
+
169
+ next unless value
170
+
164
171
  if field == 'pace'
165
172
  # Slow speeds lead to very large pace values that make the graph
166
173
  # hard to read. We cap the pace at 20.0 min/km to keep it readable.
@@ -18,6 +18,8 @@ module PostRunner
18
18
 
19
19
  class DeviceList
20
20
 
21
+ include Fit4Ruby::Converters
22
+
21
23
  def initialize(fit_activity)
22
24
  @fit_activity = fit_activity
23
25
  end
@@ -46,15 +48,19 @@ module PostRunner
46
48
  t.body
47
49
 
48
50
  t.cell('Manufacturer:', { :width => '40%' })
49
- t.cell(device.manufacturer, { :width => '60%' })
51
+ t.cell(device.manufacturer.upcase, { :width => '60%' })
50
52
  t.new_row
51
53
 
52
- if (product = device.product)
54
+ if (product = %w( garmin dynastream dynastream_oem ).include?(
55
+ device.manufacturer) ?
56
+ device.garmin_product : device.product)
57
+ # For unknown products the numerical ID will be returned.
58
+ product = product.to_s unless product.is_a?(String)
53
59
  t.cell('Product:')
54
- rename = { 'fr620' => 'FR620', 'sdm4' => 'SDM4',
55
- 'hrm_run_single_byte_product_id' => 'HRM Run',
60
+ # Beautify some product names. The others will just be upcased.
61
+ rename = { 'hrm_run_single_byte_product_id' => 'HRM Run',
56
62
  'hrm_run' => 'HRM Run' }
57
- product = rename[product] if rename.include?(product)
63
+ product = rename.include?(product) ? rename[product] : product.upcase
58
64
  t.cell(product)
59
65
  t.new_row
60
66
  end
@@ -87,6 +93,11 @@ module PostRunner
87
93
  t.cell(device.battery_status)
88
94
  t.new_row
89
95
  end
96
+ if device.cum_operating_time
97
+ t.cell('Cumulated Operating Time:')
98
+ t.cell(secsToDHMS(device.cum_operating_time))
99
+ t.new_row
100
+ end
90
101
 
91
102
  seen_indexes << device.device_index
92
103
  end
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = EPO_Downloader.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 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 'uri'
14
+ require 'net/http'
15
+
16
+ module PostRunner
17
+
18
+ # This class can download the current set of Extended Prediction Orbit data
19
+ # for GPS satellites and store them in the EPO.BIN file format. Some Garmin
20
+ # devices pick up this file under GARMIN/GARMIN/REMOTESW/EPO.BIN.
21
+ class EPO_Downloader
22
+
23
+ @@URI = URI('http://omt.garmin.com/Rce/ProtobufApi/EphemerisService/GetEphemerisData')
24
+ # This is the payload of the POST request. It was taken from
25
+ # http://www.kluenter.de/garmin-ephemeris-files-and-linux/. It may contain
26
+ # a product ID or serial number.
27
+ @@POST_DATA = "\n-\n\aexpress\u0012\u0005de_DE\u001A\aWindows\"\u0012601 Service Pack 1\u0012\n\b\x8C\xB4\x93\xB8\u000E\u0012\u0000\u0018\u0000\u0018\u001C\"\u0000"
28
+ @@HEADER = {
29
+ 'Garmin-Client-Name' => 'CoreService',
30
+ 'Content-Type' => 'application/octet-stream',
31
+ 'Content-Length' => '63'
32
+ }
33
+
34
+ # Create an EPO_Downloader object.
35
+ def initialize
36
+ @http = Net::HTTP.new(@@URI.host, @@URI.port)
37
+ @request = Net::HTTP::Post.new(@@URI.path, initheader = @@HEADER)
38
+ @request.body = @@POST_DATA
39
+ end
40
+
41
+ # Download the current EPO data from the Garmin server and write it into
42
+ # the specified output file.
43
+ # @param output_file [String] The name of the output file. Usually this is
44
+ # 'EPO.BIN'.
45
+ def download(output_file)
46
+ return false unless (epo = get_epo_from_server)
47
+ return false unless (epo = fix(epo))
48
+ return false unless check_epo_data(epo)
49
+ write_file(output_file, epo)
50
+ Log.info "Extended Prediction Orbit (EPO) data has been downloaded " +
51
+ "from Garmin site."
52
+
53
+ true
54
+ end
55
+
56
+ private
57
+
58
+ def get_epo_from_server
59
+ begin
60
+ res = @http.request(@request)
61
+ rescue => e
62
+ Log.error "Extended Prediction Orbit (EPO) data download error: " +
63
+ e.message
64
+ return nil
65
+ end
66
+ if res.code.to_i != 200
67
+ Log.error "Extended Prediction Orbit (EPO) data download failed: #{res}"
68
+ return nil
69
+ end
70
+ res.body
71
+ end
72
+
73
+ # The downloaded data contains Extended Prediction Orbit data for 6 hour
74
+ # windows for 7 days. Each EPO set is 2307 bytes long, but the first 3
75
+ # bytes must be removed for the FR620 to understand it.
76
+ # https://forums.garmin.com/showthread.php?79555-when-will-garmin-express-mac-be-able-to-sync-GPS-EPO-bin-file-on-fenix-2&p=277398#post277398
77
+ # The 2304 bytes consist of 32 sets of 72 byte GPS satellite data.
78
+ # http://www.vis-plus.ee/pdf/SIM28_SIM68R_SIM68V_EPO-II_Protocol_V1.00.pdf
79
+ def fix(epo)
80
+ unless epo.length == 28 * 2307
81
+ Log.error "GPS data has unexpected length of #{epo.length} bytes"
82
+ return nil
83
+ end
84
+
85
+ epo_fixed = ''
86
+ 0.upto(27) do |i|
87
+ offset = i * 2307
88
+ # The fill bytes always seem to be 0. Let's issue a warning in case
89
+ # this ever changes.
90
+ unless epo[offset].to_i == 0 &&
91
+ epo[offset + 1].to_i == 0 &&
92
+ epo[offset + 2].to_i == 0
93
+ Log.warning "EPO fill bytes are not 0 bytes"
94
+ end
95
+ epo_fixed += epo[offset + 3, 2304]
96
+ end
97
+
98
+ epo_fixed
99
+ end
100
+
101
+ def check_epo_data(epo)
102
+ # Convert EPO string into Array of bytes.
103
+ epo = epo.bytes.to_a
104
+ unless epo.length == 28 * 72 * 32
105
+ Log.error "EPO file has wrong length (#{epo.length})"
106
+ return false
107
+ end
108
+ date_1980_01_06 = Time.parse("1980-01-06T00:00:00+00:00")
109
+ now = Time.now
110
+ end_date = start_date = nil
111
+ # Split the EPO data into Arrays of 32 * 72 bytes.
112
+ epo.each_slice(32 * 72).to_a.each do |epo_set|
113
+ # For each of the 32 satellites we have 72 bytes of data.
114
+ epo_set.each_slice(72).to_a.each do |sat|
115
+ # The last byte is an XOR checksum of the first 71 bytes.
116
+ xor = 0
117
+ 0.upto(70) { |i| xor ^= sat[i] }
118
+ unless xor == sat[71]
119
+ Log.error "Checksum error in EPO file"
120
+ return false
121
+ end
122
+ # The first 3 bytes of every satellite record look like a timestamp.
123
+ # I assume they are hours after January 6th, 1980 UTC. They probably
124
+ # indicate the start of the 6 hour window that the data is for.
125
+ hours_after_1980_01_06 = sat[0] | (sat[1] << 8) | (sat[2] << 16)
126
+ date = date_1980_01_06 + hours_after_1980_01_06 * 60 * 60
127
+ if date > now + 8 * 24 * 60 * 60
128
+ Log.warn "EPO timestamp (#{date}) is too far in the future"
129
+ elsif date < now - 24 * 60 * 60
130
+ Log.warn "EPO timestamp (#{date}) is too old"
131
+ end
132
+ start_date = date if start_date.nil? || date < start_date
133
+ end_date = date if end_date.nil? || date > end_date
134
+ end
135
+ end
136
+ Log.info "EPO data is valid from #{start_date} to " +
137
+ "#{end_date + 6 * 60 * 60}."
138
+
139
+ true
140
+ end
141
+
142
+ def write_file(output_file, data)
143
+ begin
144
+ File.write(output_file, data)
145
+ rescue IOError
146
+ Log.fatal "Cannot write EPO file '#{output_file}': #{$!}"
147
+ end
148
+ end
149
+
150
+ end
151
+
152
+ end
153
+
@@ -17,6 +17,7 @@ require 'fit4ruby'
17
17
  require 'postrunner/version'
18
18
  require 'postrunner/RuntimeConfig'
19
19
  require 'postrunner/ActivitiesDB'
20
+ require 'postrunner/EPO_Downloader'
20
21
 
21
22
  module PostRunner
22
23
 
@@ -115,51 +116,56 @@ EOT
115
116
  Commands:
116
117
 
117
118
  check [ <fit file> | <ref> ... ]
118
- Check the provided FIT file(s) for structural errors. If no file or
119
- reference is provided, the complete archive is checked.
119
+ Check the provided FIT file(s) for structural errors. If no file or
120
+ reference is provided, the complete archive is checked.
120
121
 
121
122
  dump <fit file> | <ref>
122
- Dump the content of the FIT file.
123
+ Dump the content of the FIT file.
123
124
 
124
125
  import [ <fit file> | <directory> ]
125
- Import the provided FIT file(s) into the postrunner database. If no
126
- file or directory is provided, the directory that was used for the
127
- previous import is being used.
126
+ Import the provided FIT file(s) into the postrunner database. If no
127
+ file or directory is provided, the directory that was used for the
128
+ previous import is being used.
128
129
 
129
130
  delete <ref>
130
- Delete the activity from the archive.
131
+ Delete the activity from the archive.
131
132
 
132
133
  list
133
- List all FIT files stored in the data base.
134
+ List all FIT files stored in the data base.
134
135
 
135
136
  records
136
- List all personal records.
137
+ List all personal records.
137
138
 
138
139
  rename <new name> <ref>
139
- For the specified activities replace current activity name with a
140
- new name that describes the activity. By default the activity name
141
- matches the FIT file name.
140
+ For the specified activities replace current activity name with a
141
+ new name that describes the activity. By default the activity name
142
+ matches the FIT file name.
142
143
 
143
144
  set <attribute> <value> <ref>
144
- For the specified activies set the attribute to the given value. The
145
- following attributes are supported:
145
+ For the specified activies set the attribute to the given value. The
146
+ following attributes are supported:
146
147
 
147
- name: The activity name (defaults to FIT file name)
148
- type: The type of the activity
149
- subtype: The subtype of the activity
148
+ name: The activity name (defaults to FIT file name)
149
+ norecord: Ignore all records from this activity (value must true
150
+ or false)
151
+ type: The type of the activity
152
+ subtype: The subtype of the activity
150
153
 
151
154
  show [ <ref> ]
152
- Show the referenced FIT activity in a web browser. If no reference
153
- is provided show the list of activities in the database.
155
+ Show the referenced FIT activity in a web browser. If no reference
156
+ is provided show the list of activities in the database.
154
157
 
155
158
  summary <ref>
156
- Display the summary information for the FIT file.
159
+ Display the summary information for the FIT file.
157
160
 
158
161
  units <metric | statute>
159
- Change the unit system.
162
+ Change the unit system.
160
163
 
161
164
  htmldir <directory>
162
- Change the output directory for the generated HTML files
165
+ Change the output directory for the generated HTML files
166
+
167
+ update-gps Download the current set of GPS Extended Prediction Orbit (EPO)
168
+ data and store them on the device.
163
169
 
164
170
 
165
171
  <fit file> An absolute or relative name of a .FIT file.
@@ -239,6 +245,8 @@ EOT
239
245
  change_unit_system(args)
240
246
  when 'htmldir'
241
247
  change_html_dir(args)
248
+ when 'update-gps'
249
+ update_gps_data
242
250
  when nil
243
251
  Log.fatal("No command provided. " +
244
252
  "See 'postrunner -h' for more information.")
@@ -352,6 +360,38 @@ EOT
352
360
  end
353
361
  end
354
362
 
363
+ def update_gps_data
364
+ epo_dir = File.join(@db_dir, 'epo')
365
+ @cfg.create_directory(epo_dir, 'GPS Data Cache')
366
+ epo_file = File.join(epo_dir, 'EPO.BIN')
367
+
368
+ if !File.exists?(epo_file) ||
369
+ (File.mtime(epo_file) < Time.now - (6 * 60 * 60))
370
+ # The EPO file only changes every 6 hours. No need to download it more
371
+ # frequently if it already exists.
372
+ if EPO_Downloader.new.download(epo_file)
373
+ unless (remotesw_dir = @cfg[:import_dir])
374
+ Log.error "No device directory set. Please import an activity " +
375
+ "from your device first."
376
+ return
377
+ end
378
+ remotesw_dir = File.join(remotesw_dir, '..', 'REMOTESW')
379
+ unless Dir.exists?(remotesw_dir)
380
+ Log.error "Cannot find '#{remotesw_dir}'. Please connect and " +
381
+ "mount your Garmin device."
382
+ return
383
+ end
384
+ begin
385
+ FileUtils.cp(epo_file, remotesw_dir)
386
+ rescue
387
+ Log.error "Cannot copy EPO.BIN file to your device at " +
388
+ "'#{remotesw_dir}'."
389
+ return
390
+ end
391
+ end
392
+ end
393
+ end
394
+
355
395
  def handle_version_update
356
396
  if @cfg.get_option(:version) != VERSION
357
397
  Log.warn "PostRunner version upgrade detected."
@@ -20,12 +20,16 @@ require 'postrunner/ActivityLink'
20
20
 
21
21
  module PostRunner
22
22
 
23
+ # The PersonalRecords class stores the various records. Records are grouped
24
+ # by specific year or all-time records.
23
25
  class PersonalRecords
24
26
 
25
27
  include Fit4Ruby::Converters
26
28
 
29
+ # List of popular distances for each sport.
27
30
  SpeedRecordDistances = {
28
31
  'cycling' => {
32
+ 1000.0 => '1 km',
29
33
  5000.0 => '5 km',
30
34
  8000.0 => '8 km',
31
35
  9000.0 => '9 km',
@@ -38,6 +42,9 @@ module PostRunner
38
42
  18000.0 => '180 km',
39
43
  },
40
44
  'running' => {
45
+ 400.0 => '400 m',
46
+ 500.0 => '500 m',
47
+ 800.0 => '800 m',
41
48
  1000.0 => '1 km',
42
49
  1609.0 => '1 mi',
43
50
  2000.0 => '2 km',
@@ -61,6 +68,7 @@ module PostRunner
61
68
  3860.0 => '2.4 mi'
62
69
  },
63
70
  'walking' => {
71
+ 500.0 => '500 m',
64
72
  1000.0 => '1 km',
65
73
  1609.0 => '1 mi',
66
74
  5000.0 => '5 km',
@@ -70,6 +78,8 @@ module PostRunner
70
78
  }
71
79
  }
72
80
 
81
+ # The Record class stores a single speed or longest distance record. It
82
+ # also stores a reference to the Activity that contains the record.
73
83
  class Record
74
84
 
75
85
  include Fit4Ruby::Converters
@@ -86,7 +96,7 @@ module PostRunner
86
96
 
87
97
  def to_table_row(t)
88
98
  t.row((@duration.nil? ?
89
- [ 'Longest Run', '%.1f m' % @distance, '-' ] :
99
+ [ 'Longest Distance', '%.3f km' % (@distance / 1000.0), '-' ] :
90
100
  [ PersonalRecords::SpeedRecordDistances[@sport][@distance],
91
101
  secsToHMS(@duration),
92
102
  speedToPace(@distance / @duration) ]) +
@@ -106,7 +116,7 @@ module PostRunner
106
116
  def initialize(sport, year)
107
117
  @sport = sport
108
118
  @year = year
109
- @distance = nil
119
+ @distance_record = nil
110
120
  @speed_records = {}
111
121
  PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
112
122
  @speed_records[dist] = nil
@@ -133,8 +143,9 @@ module PostRunner
133
143
  end
134
144
  else
135
145
  # We have a potential distance record.
136
- if @distance.nil? || result.distance > @distance.distance
137
- @distance = result
146
+ if @distance_record.nil? ||
147
+ @distance_record.distance < result.distance
148
+ @distance_record = result
138
149
  Log.info "New #{@year ? @year.to_s : 'all-time'} " +
139
150
  "#{result.sport} distance record: #{result.distance} m"
140
151
  return true
@@ -145,8 +156,8 @@ module PostRunner
145
156
  end
146
157
 
147
158
  def delete_activity(activity)
148
- if @distance && @distance.activity == activity
149
- @distance = nil
159
+ if @distance_record && @distance_record.activity == activity
160
+ @distance_record = nil
150
161
  end
151
162
  PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
152
163
  if @speed_records[dist] && @speed_records[dist].activity == activity
@@ -157,7 +168,7 @@ module PostRunner
157
168
 
158
169
  # Return true if no Record is stored in this RecordSet object.
159
170
  def empty?
160
- return false if @distance
171
+ return false if @distance_record
161
172
  @speed_records.each_value { |r| return false if r }
162
173
 
163
174
  true
@@ -165,7 +176,7 @@ module PostRunner
165
176
 
166
177
  # Iterator for all Record objects that are stored in this data structure.
167
178
  def each(&block)
168
- yield(@distance) if @distance
179
+ yield(@distance_record) if @distance_record
169
180
  @speed_records.each_value do |record|
170
181
  yield(record) if record
171
182
  end
@@ -200,7 +211,7 @@ module PostRunner
200
211
  t.body
201
212
 
202
213
  records = @speed_records.values.delete_if { |r| r.nil? }
203
- records << @distance if @distance
214
+ records << @distance_record if @distance_record
204
215
 
205
216
  records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
206
217
  r.to_table_row(t)
@@ -328,6 +339,7 @@ module PostRunner
328
339
  "records-#{i}.html")
329
340
  RecordListPageView.new(@activities, record, max, i).
330
341
  write(output_file)
342
+ i += 1
331
343
  end
332
344
  end
333
345
 
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = QueryResult.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 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
+ module PostRunner
14
+
15
+ # Queries provide an abstract interface to retrieve individual values from
16
+ # Activities, Laps and so on. The result of a query is returned as a
17
+ # QueryResult object.
18
+ class QueryResult
19
+
20
+ # Create a QueryResult object.
21
+ # @param value [any] Result of the query
22
+ # @param schema [Schema] A reference to the Schema of the queried
23
+ # attribute.
24
+ def initialize(value, schema)
25
+ @value = value
26
+ @schema = schema
27
+ end
28
+
29
+ # Conver the result into a text String.
30
+ def to_s
31
+ @schema.to_s(@value)
32
+ end
33
+
34
+ end
35
+
36
+ end
@@ -55,7 +55,8 @@ module PostRunner
55
55
  ViewFrame.new("All-time #{@sport_name} Records",
56
56
  frame_width, @records.all_time).to_html(@doc)
57
57
 
58
- @records.yearly.each do |year, record|
58
+ @records.yearly.sort{ |y1, y2| y2[0] <=> y1[0] }.
59
+ each do |year, record|
59
60
  next if record.empty?
60
61
  ViewFrame.new("#{year} #{@sport_name} Records",
61
62
  frame_width, record).to_html(@doc)
@@ -56,6 +56,18 @@ module PostRunner
56
56
  save_options
57
57
  end
58
58
 
59
+ # Ensure that the requested directory exists.
60
+ def create_directory(dir, name)
61
+ return if Dir.exists?(dir)
62
+
63
+ Log.info "Creating #{name} directory #{dir}"
64
+ begin
65
+ Dir.mkdir(dir)
66
+ rescue StandardError
67
+ Log.fatal "Cannot create #{name} directory #{dir}: #{$!}"
68
+ end
69
+ end
70
+
59
71
  private
60
72
 
61
73
  def load_options
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = Schema.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 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
+ module PostRunner
14
+
15
+ # A Schema provides a unified way to query and process diverse data types.
16
+ class Schema
17
+
18
+ attr_reader :key, :name,
19
+ :func, :column_alignment, :metric_unit, :imperial_unit
20
+
21
+ # Create a Schema object.
22
+ # @param key [Symbol] The globally unique identifier for the object
23
+ # @param name [String] A human readable name to describe the object
24
+ # @param opts [Hash] A Hash with values to overwrite the default values
25
+ # of some instance variables.
26
+ def initialize(key, name, opts = {})
27
+ @key = key
28
+ @name = name
29
+
30
+ # Default values for optional variables
31
+ @func = nil
32
+ @format = nil
33
+ @column_alignment = :right
34
+ @metric_unit = nil
35
+ @imperial_unit = nil
36
+
37
+ # Overwrite the default value for optional variables that have values
38
+ # provided in opts.
39
+ opts.each do |on, ov|
40
+ if instance_variable_defined?('@' + on.to_s)
41
+ instance_variable_set('@' + on.to_s, ov)
42
+ else
43
+ raise ArgumentError, "Unknown instance variable '#{on}'"
44
+ end
45
+ end
46
+ end
47
+
48
+ def to_s(value)
49
+ value = send(@format, value) if @format
50
+ value.to_s
51
+ end
52
+
53
+ private
54
+
55
+ def date_with_weekday(timestamp)
56
+ timestamp.strftime('%a, %Y %b %d %H:%M')
57
+ end
58
+
59
+ end
60
+
61
+ end
62
+
@@ -11,5 +11,5 @@
11
11
  #
12
12
 
13
13
  module PostRunner
14
- VERSION = "0.0.8"
14
+ VERSION = "0.0.9"
15
15
  end
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ["lib"]
20
20
  spec.required_ruby_version = '>=2.0'
21
21
 
22
- spec.add_dependency 'fit4ruby', '~> 0.0.5'
22
+ spec.add_dependency 'fit4ruby', '~> 0.0.6'
23
23
  spec.add_dependency 'nokogiri', '~> 1.6'
24
24
 
25
25
  spec.add_development_dependency 'bundler', '~> 1.6'
@@ -13,11 +13,15 @@
13
13
  require 'postrunner/ActivitySummary'
14
14
  require 'spec_helper'
15
15
 
16
+ class Activity < Struct.new(:fit_activity, :sport)
17
+ end
18
+
16
19
  describe PostRunner::ActivitySummary do
17
20
 
18
21
  before(:each) do
19
- @as = PostRunner::ActivitySummary.new(
20
- create_fit_activity('2014-08-26-19:00', 30), :metric,
22
+ fa = create_fit_activity('2014-08-26-19:00', 30)
23
+ a = Activity.new(fa, 'running')
24
+ @as = PostRunner::ActivitySummary.new(a, :metric,
21
25
  { :name => 'test', :type => 'Running',
22
26
  :sub_type => 'Street' })
23
27
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: postrunner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Schlaeger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-04-12 00:00:00.000000000 Z
11
+ date: 2015-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fit4ruby
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ~>
18
18
  - !ruby/object:Gem::Version
19
- version: 0.0.5
19
+ version: 0.0.6
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ~>
25
25
  - !ruby/object:Gem::Version
26
- version: 0.0.5
26
+ version: 0.0.6
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: nokogiri
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -121,14 +121,17 @@ files:
121
121
  - lib/postrunner/BackedUpFile.rb
122
122
  - lib/postrunner/ChartView.rb
123
123
  - lib/postrunner/DeviceList.rb
124
+ - lib/postrunner/EPO_Downloader.rb
124
125
  - lib/postrunner/FlexiTable.rb
125
126
  - lib/postrunner/HTMLBuilder.rb
126
127
  - lib/postrunner/Main.rb
127
128
  - lib/postrunner/NavButtonRow.rb
128
129
  - lib/postrunner/PagingButtons.rb
129
130
  - lib/postrunner/PersonalRecords.rb
131
+ - lib/postrunner/QueryResult.rb
130
132
  - lib/postrunner/RecordListPageView.rb
131
133
  - lib/postrunner/RuntimeConfig.rb
134
+ - lib/postrunner/Schema.rb
132
135
  - lib/postrunner/TrackView.rb
133
136
  - lib/postrunner/UserProfileView.rb
134
137
  - lib/postrunner/View.rb