postrunner 0.0.8 → 0.0.9

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.
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