postrunner 0.0.4 → 0.0.5

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.
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = ActivitySummary.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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/ViewWidgets'
17
+
18
+ module PostRunner
19
+
20
+ class ActivitySummary
21
+
22
+ include Fit4Ruby::Converters
23
+ include ViewWidgets
24
+
25
+ def initialize(fit_activity, name, unit_system)
26
+ @fit_activity = fit_activity
27
+ @name = name
28
+ @unit_system = unit_system
29
+ end
30
+
31
+ def to_s
32
+ session = @fit_activity.sessions[0]
33
+
34
+ summary(session).to_s + "\n" + laps.to_s
35
+ end
36
+
37
+ def to_html(doc)
38
+ session = @fit_activity.sessions[0]
39
+
40
+ frame(doc, "Activity: #{@name}") {
41
+ summary(session).to_html(doc)
42
+ }
43
+ frame(doc, 'Laps') {
44
+ laps.to_html(doc)
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def summary(session)
51
+ t = FlexiTable.new
52
+ t.enable_frame(false)
53
+ t.body
54
+ t.row([ 'Date:', session.timestamp])
55
+ t.row([ 'Distance:',
56
+ local_value(session, 'total_distance', '%.2f %s',
57
+ { :metric => 'km', :statute => 'mi'}) ])
58
+ t.row([ 'Time:', secsToHMS(session.total_timer_time) ])
59
+ t.row([ 'Avg. Pace:', pace(session, 'avg_speed') ])
60
+ t.row([ 'Total Ascent:',
61
+ local_value(session, 'total_ascent', '%.0f %s',
62
+ { :metric => 'm', :statute => 'ft' }) ])
63
+ t.row([ 'Total Descent:',
64
+ local_value(session, 'total_descent', '%.0f %s',
65
+ { :metric => 'm', :statute => 'ft' }) ])
66
+ t.row([ 'Calories:', "#{session.total_calories} kCal" ])
67
+ t.row([ 'Avg. HR:', session.avg_heart_rate ?
68
+ "#{session.avg_heart_rate} bpm" : '-' ])
69
+ t.row([ 'Max. HR:', session.max_heart_rate ?
70
+ "#{session.max_heart_rate} bpm" : '-' ])
71
+ t.row([ 'Training Effect:', session.total_training_effect ?
72
+ session.total_training_effect : '-' ])
73
+ t.row([ 'Avg. Run Cadence:',
74
+ session.avg_running_cadence ?
75
+ "#{session.avg_running_cadence.round} spm" : '-' ])
76
+ t.row([ 'Avg. Vertical Oscillation:',
77
+ local_value(session, 'avg_vertical_oscillation', '%.1f %s',
78
+ { :metric => 'cm', :statute => 'in' }) ])
79
+ t.row([ 'Avg. Ground Contact Time:',
80
+ session.avg_stance_time ?
81
+ "#{session.avg_stance_time.round} ms" : '-' ])
82
+ t.row([ 'Avg. Stride Length:',
83
+ local_value(session, 'avg_stride_length', '%.2f %s',
84
+ { :metric => 'm', :statute => 'ft' }) ])
85
+ rec_time = @fit_activity.recovery_time
86
+ t.row([ 'Recovery Time:', rec_time ? secsToHMS(rec_time * 60) : '-' ])
87
+ vo2max = @fit_activity.vo2max
88
+ t.row([ 'VO2max:', vo2max ? vo2max : '-' ])
89
+
90
+ t
91
+ end
92
+
93
+ def laps
94
+ t = FlexiTable.new
95
+ t.head
96
+ t.row([ 'Lap', 'Duration', 'Distance', 'Avg. Pace', 'Stride', 'Cadence',
97
+ 'Avg. HR', 'Max. HR' ])
98
+ t.set_column_attributes(Array.new(8, { :halign => :right }))
99
+ t.body
100
+ @fit_activity.sessions[0].laps.each.with_index do |lap, index|
101
+ t.cell(index + 1)
102
+ t.cell(secsToHMS(lap.total_timer_time))
103
+ t.cell(local_value(lap, 'total_distance', '%.2f',
104
+ { :metric => 'km', :statute => 'mi' }))
105
+ t.cell(pace(lap, 'avg_speed', false))
106
+ t.cell(local_value(lap, 'avg_stride_length', '%.2f',
107
+ { :metric => 'm', :statute => 'ft' }))
108
+ t.cell(lap.avg_running_cadence && lap.avg_fractional_cadence ?
109
+ '%.1f' % (2 * lap.avg_running_cadence +
110
+ (2 * lap.avg_fractional_cadence) / 100.0) : '')
111
+ t.cell(lap.avg_heart_rate.to_s)
112
+ t.cell(lap.max_heart_rate.to_s)
113
+ t.new_row
114
+ end
115
+
116
+ t
117
+ end
118
+
119
+ def local_value(fdr, field, format, units)
120
+ unit = units[@unit_system]
121
+ value = fdr.get_as(field, unit)
122
+ return '-' unless value
123
+ "#{format % [value, unit]}"
124
+ end
125
+
126
+ def pace(fdr, field, show_unit = true)
127
+ speed = fdr.get(field)
128
+ case @unit_system
129
+ when :metric
130
+ "#{speedToPace(speed)}#{show_unit ? ' min/km' : ''}"
131
+ when :statute
132
+ "#{speedToPace(speed, 1609.34)}#{show_unit ? ' min/mi' : ''}"
133
+ else
134
+ Log.fatal "Unknown unit system #{@unit_system}"
135
+ end
136
+ end
137
+
138
+ end
139
+
140
+ end
141
+
@@ -1,7 +1,21 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = ActivityView.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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
+
1
13
  require 'fit4ruby'
2
14
 
3
15
  require 'postrunner/HTMLBuilder'
4
- require 'postrunner/ActivityReport'
16
+ require 'postrunner/ActivitySummary'
17
+ require 'postrunner/DeviceList'
18
+ require 'postrunner/UserProfileView'
5
19
  require 'postrunner/ViewWidgets'
6
20
  require 'postrunner/TrackView'
7
21
  require 'postrunner/ChartView'
@@ -12,61 +26,28 @@ module PostRunner
12
26
 
13
27
  include ViewWidgets
14
28
 
15
- def initialize(activity, output_dir)
29
+ def initialize(activity, unit_system, predecessor, successor)
16
30
  @activity = activity
17
- @output_dir = output_dir
31
+ @unit_system = unit_system
32
+ @predecessor = predecessor
33
+ @successor = successor
34
+ @output_dir = activity.html_dir
18
35
  @output_file = nil
19
36
 
20
- ensure_output_dir
21
-
22
37
  @doc = HTMLBuilder.new
23
38
  generate_html(@doc)
24
39
  write_file
25
- show_in_browser
26
40
  end
27
41
 
28
42
  private
29
43
 
30
- def ensure_output_dir
31
- unless Dir.exists?(@output_dir)
32
- begin
33
- Dir.mkdir(@output_dir)
34
- rescue SystemCallError
35
- Log.fatal "Cannot create output directory '#{@output_dir}': #{$!}"
36
- end
37
- end
38
- create_symlink('jquery')
39
- create_symlink('flot')
40
- create_symlink('openlayers')
41
- end
42
-
43
- def create_symlink(dir)
44
- # This file should be in lib/postrunner. The 'misc' directory should be
45
- # found in '../../misc'.
46
- misc_dir = File.realpath(File.join(File.dirname(__FILE__),
47
- '..', '..', 'misc'))
48
- unless Dir.exists?(misc_dir)
49
- Log.fatal "Cannot find 'misc' directory under '#{misc_dir}': #{$!}"
50
- end
51
- src_dir = File.join(misc_dir, dir)
52
- unless Dir.exists?(src_dir)
53
- Log.fatal "Cannot find '#{src_dir}': #{$!}"
54
- end
55
- dst_dir = File.join(@output_dir, dir)
56
- unless File.exists?(dst_dir)
57
- begin
58
- FileUtils.ln_s(src_dir, dst_dir)
59
- rescue IOError
60
- Log.fatal "Cannot create symbolic link to '#{dst_dir}': #{$!}"
61
- end
62
- end
63
- end
64
-
65
-
66
44
  def generate_html(doc)
67
- @report = ActivityReport.new(@activity.fit_activity)
45
+ @report = ActivitySummary.new(@activity.fit_activity, @activity.name,
46
+ @unit_system)
47
+ @device_list = DeviceList.new(@activity.fit_activity)
48
+ @user_profile = UserProfileView.new(@activity.fit_activity, @unit_system)
68
49
  @track_view = TrackView.new(@activity)
69
- @chart_view = ChartView.new(@activity)
50
+ @chart_view = ChartView.new(@activity, @unit_system)
70
51
 
71
52
  doc.html {
72
53
  head(doc)
@@ -83,15 +64,19 @@ module PostRunner
83
64
  'initial-scale=1.0, maximum-scale=1.0, ' +
84
65
  'user-scalable=0' })
85
66
  doc.title("PostRunner Activity: #{@activity.name}")
86
- style(doc)
87
67
  view_widgets_style(doc)
88
68
  @chart_view.head(doc)
89
69
  @track_view.head(doc)
70
+ style(doc)
90
71
  }
91
72
  end
92
73
 
93
74
  def style(doc)
94
75
  doc.style(<<EOT
76
+ body {
77
+ font-family: verdana,arial,sans-serif;
78
+ margin: 0px;
79
+ }
95
80
  .main {
96
81
  width: 1210px;
97
82
  margin: 0 auto;
@@ -104,39 +89,28 @@ module PostRunner
104
89
  float: right;
105
90
  width: 600px;
106
91
  }
107
- .widget_container {
108
- box-sizing: border-box;
109
- width: 600px;
110
- padding: 10px 15px 15px 15px;
111
- margin: 15px auto 15px auto;
112
- border: 1px solid #ddd;
113
- background: #fff;
114
- background: linear-gradient(#f6f6f6 0, #fff 50px);
115
- background: -o-linear-gradient(#f6f6f6 0, #fff 50px);
116
- background: -ms-linear-gradient(#f6f6f6 0, #fff 50px);
117
- background: -moz-linear-gradient(#f6f6f6 0, #fff 50px);
118
- background: -webkit-linear-gradient(#f6f6f6 0, #fff 50px);
119
- box-shadow: 0 3px 10px rgba(0,0,0,0.15);
120
- -o-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
121
- -ms-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
122
- -moz-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
123
- -webkit-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
124
- }
125
92
  EOT
126
93
  )
127
94
  end
128
95
 
129
96
  def body(doc)
130
- doc.body({ 'onload' => 'init()' }) {
131
- doc.div({ 'class' => 'main' }) {
132
- doc.div({ 'class' => 'left_col' }) {
97
+ doc.body({ :onload => 'init()' }) {
98
+ prev_page = @predecessor ? @predecessor.fit_file[0..-5] + '.html' : nil
99
+ next_page = @successor ? @successor.fit_file[0..-5] + '.html' : nil
100
+ titlebar(doc, nil, prev_page, 'index.html', next_page)
101
+ # The main area with the 2 column layout.
102
+ doc.div({ :class => 'main' }) {
103
+ doc.div({ :class => 'left_col' }) {
133
104
  @report.to_html(doc)
134
105
  @track_view.div(doc)
106
+ @device_list.to_html(doc)
107
+ @user_profile.to_html(doc)
135
108
  }
136
- doc.div({ 'class' => 'right_col' }) {
109
+ doc.div({ :class => 'right_col' }) {
137
110
  @chart_view.div(doc)
138
111
  }
139
112
  }
113
+ footer(doc)
140
114
  }
141
115
  end
142
116
 
@@ -149,10 +123,6 @@ EOT
149
123
  end
150
124
  end
151
125
 
152
- def show_in_browser
153
- system("firefox \"#{@output_file}\" &")
154
- end
155
-
156
126
  end
157
127
 
158
128
  end
@@ -1,3 +1,15 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = ChartView.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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
+
1
13
  require 'postrunner/ViewWidgets'
2
14
 
3
15
  module PostRunner
@@ -6,8 +18,10 @@ module PostRunner
6
18
 
7
19
  include ViewWidgets
8
20
 
9
- def initialize(activity)
21
+ def initialize(activity, unit_system)
10
22
  @activity = activity
23
+ @unit_system = unit_system
24
+ @empty_charts = {}
11
25
  end
12
26
 
13
27
  def head(doc)
@@ -21,16 +35,29 @@ module PostRunner
21
35
  end
22
36
 
23
37
  def div(doc)
24
- chart_div(doc, 'pace', 'Pace (min/km)')
25
- chart_div(doc, 'altitude', 'Elevation (m)')
38
+ chart_div(doc, 'pace', "Pace (#{select_unit('min/km')})")
39
+ chart_div(doc, 'altitude', "Elevation (#{select_unit('m')})")
26
40
  chart_div(doc, 'heart_rate', 'Heart Rate (bpm)')
27
- chart_div(doc, 'cadence', 'Run Cadence (spm)')
28
- chart_div(doc, 'vertical_oscillation', 'Vertical Oscillation (cm)')
41
+ chart_div(doc, 'run_cadence', 'Run Cadence (spm)')
42
+ chart_div(doc, 'vertical_oscillation',
43
+ "Vertical Oscillation (#{select_unit('cm')})")
29
44
  chart_div(doc, 'stance_time', 'Ground Contact Time (ms)')
30
45
  end
31
46
 
32
47
  private
33
48
 
49
+ def select_unit(metric_unit)
50
+ case @unit_system
51
+ when :metric
52
+ metric_unit
53
+ when :statute
54
+ { 'min/km' => 'min/mi', 'm' => 'ft', 'cm' => 'in',
55
+ 'bpm' => 'bpm', 'spm' => 'spm', 'ms' => 'ms' }[metric_unit]
56
+ else
57
+ Log.fatal "Unknown unit system #{@unit_system}"
58
+ end
59
+ end
60
+
34
61
  def style
35
62
  <<EOT
36
63
  .chart-container {
@@ -53,7 +80,7 @@ module PostRunner
53
80
  -webkit-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
54
81
  }
55
82
  .chart-placeholder {
56
- width: 570px;
83
+ width: 580px;
57
84
  height: 200px;
58
85
  font-size: 14px;
59
86
  line-height: 1.2em;
@@ -64,22 +91,22 @@ EOT
64
91
  def java_script
65
92
  s = "$(function() {\n"
66
93
 
67
- s << line_graph('pace', '#0A7BEE' )
68
- s << line_graph('altitude', '#5AAA44')
69
- s << line_graph('heart_rate', '#900000')
70
- s << point_graph('cadence',
94
+ s << line_graph('pace', 'min/km', '#0A7BEE' )
95
+ s << line_graph('altitude', 'm', '#5AAA44')
96
+ s << line_graph('heart_rate', 'bpm', '#900000')
97
+ s << point_graph('run_cadence', 'spm',
71
98
  [ [ '#EE3F2D', 151 ],
72
99
  [ '#F79666', 163 ],
73
100
  [ '#A0D488', 174 ],
74
101
  [ '#96D7DE', 185 ],
75
- [ '#A88BBB', nil ] ], 2)
76
- s << point_graph('vertical_oscillation',
77
- [ [ '#A88BBB', 6.7 ],
78
- [ '#96D7DE', 8.4 ],
79
- [ '#A0D488', 10.1 ],
80
- [ '#F79666', 11.8 ],
81
- [ '#EE3F2D', nil ] ], 0.1)
82
- s << point_graph('stance_time',
102
+ [ '#A88BBB', nil ] ])
103
+ s << point_graph('vertical_oscillation', 'cm',
104
+ [ [ '#A88BBB', 67 ],
105
+ [ '#96D7DE', 84 ],
106
+ [ '#A0D488', 101 ],
107
+ [ '#F79666', 118 ],
108
+ [ '#EE3F2D', nil ] ])
109
+ s << point_graph('stance_time', 'ms',
83
110
  [ [ '#A88BBB', 208 ],
84
111
  [ '#96D7DE', 241 ],
85
112
  [ '#A0D488', 273 ],
@@ -91,18 +118,13 @@ EOT
91
118
  s
92
119
  end
93
120
 
94
- def line_graph(field, color = nil)
121
+ def line_graph(field, unit, color = nil)
95
122
  s = "var #{field}_data = [\n"
96
123
 
97
- first = true
124
+ data_set = []
98
125
  start_time = @activity.fit_activity.sessions[0].start_time.to_i
99
126
  @activity.fit_activity.records.each do |r|
100
- if first
101
- first = false
102
- else
103
- s << ', '
104
- end
105
- value = r.send(field)
127
+ value = r.get_as(field, select_unit(unit))
106
128
  if field == 'pace'
107
129
  if value > 20.0
108
130
  value = nil
@@ -110,17 +132,26 @@ EOT
110
132
  value = (value * 3600.0 * 1000).to_i
111
133
  end
112
134
  end
113
- s << "[ #{((r.timestamp.to_i - start_time) * 1000).to_i}, " +
114
- "#{value ? value : 'null'} ]"
135
+ data_set << [ ((r.timestamp.to_i - start_time) * 1000).to_i, value ]
115
136
  end
116
137
 
138
+ # We don't want to plot charts with all nil values.
139
+ unless data_set.find { |v| v[1] != nil }
140
+ @empty_charts[field] = true
141
+ return ''
142
+ end
143
+ s << data_set.map do |set|
144
+ "[ #{set[0]}, #{set[1] ? set[1] : 'null'} ]"
145
+ end.join(', ')
146
+
117
147
  s << <<"EOT"
118
148
  ];
119
149
 
120
150
  $.plot("##{field}_chart",
121
151
  [ { data: #{field}_data,
122
152
  #{color ? "color: \"#{color}\"," : ''}
123
- lines: { show: true#{field == 'pace' ? '' : ', fill: true'} } } ],
153
+ lines: { show: true#{field == 'pace' ? '' :
154
+ ', fill: true'} } } ],
124
155
  { xaxis: { mode: "time" }
125
156
  EOT
126
157
  if field == 'pace'
@@ -131,7 +162,7 @@ EOT
131
162
  s << "});\n"
132
163
  end
133
164
 
134
- def point_graph(field, colors, multiplier = 1)
165
+ def point_graph(field, unit, colors)
135
166
  # We need to split the field values into separate data sets for each
136
167
  # color. The max value for each color determines which set a data point
137
168
  # ends up in.
@@ -144,8 +175,7 @@ EOT
144
175
  start_time = @activity.fit_activity.sessions[0].start_time.to_i
145
176
  @activity.fit_activity.records.each do |r|
146
177
  # Undefined values will be discarded.
147
- next unless (value = r.instance_variable_get('@' + field))
148
- value *= multiplier
178
+ next unless (value = r.send(field))
149
179
 
150
180
  # Find the right set by looking at the maximum allowed values for each
151
181
  # color.
@@ -156,13 +186,19 @@ EOT
156
186
  # allowed range for this set, so add the value as x/y pair to the
157
187
  # set.
158
188
  x_val = (r.timestamp.to_i - start_time) * 1000
159
- data_sets[i] << [ x_val, value ]
189
+ data_sets[i] << [ x_val, r.get_as(field, select_unit(unit)) ]
160
190
  # Abort the color loop since we've found the right set already.
161
191
  break
162
192
  end
163
193
  end
164
194
  end
165
195
 
196
+ # We don't want to plot charts with all nil values.
197
+ if data_sets.values.flatten.empty?
198
+ @empty_charts[field] = true
199
+ return ''
200
+ end
201
+
166
202
  # Now generate the JS variable definitions for each set.
167
203
  s = ''
168
204
  data_sets.each do |index, ds|
@@ -184,6 +220,9 @@ EOT
184
220
  end
185
221
 
186
222
  def chart_div(doc, field, title)
223
+ # Don't plot frame for graph without data.
224
+ return if @empty_charts[field]
225
+
187
226
  frame(doc, title) {
188
227
  doc.div({ 'id' => "#{field}_chart", 'class' => 'chart-placeholder'})
189
228
  }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = DeviceList.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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/ViewWidgets'
16
+
17
+ module PostRunner
18
+
19
+ class DeviceList
20
+
21
+ include ViewWidgets
22
+
23
+ def initialize(fit_activity)
24
+ @fit_activity = fit_activity
25
+ end
26
+
27
+ def to_html(doc)
28
+ frame(doc, 'Devices') {
29
+ devices.each { |d| d.to_html(doc) }
30
+ }
31
+ end
32
+
33
+ def to_s
34
+ devices.map { |d| d.to_s }.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def devices
40
+ tables = []
41
+ seen_indexes = []
42
+ @fit_activity.device_infos.reverse_each do |device|
43
+ next if seen_indexes.include?(device.device_index) ||
44
+ device.manufacturer.nil? ||
45
+ device.manufacturer == 'Undocumented value 0' ||
46
+ device.device_type == 'Undocumented value 0'
47
+
48
+ tables << (t = FlexiTable.new)
49
+ t.set_html_attrs(:style, 'margin-bottom: 15px') if tables.length != 1
50
+ t.body
51
+
52
+ t.cell('Manufacturer:', { :width => '40%' })
53
+ t.cell(device.manufacturer, { :width => '60%' })
54
+ t.new_row
55
+
56
+ if (product = device.product)
57
+ t.cell('Product:')
58
+ rename = { 'fr620' => 'FR620', 'sdm4' => 'SDM4',
59
+ 'hrm_run_single_byte_product_id' => 'HRM Run',
60
+ 'hrm_run' => 'HRM Run' }
61
+ product = rename[product] if rename.include?(product)
62
+ t.cell(product)
63
+ t.new_row
64
+ end
65
+ if (type = device.device_type)
66
+ rename = { 'heart_rate' => 'Heart Rate Sensor',
67
+ 'stride_speed_distance' => 'Footpod',
68
+ 'running_dynamics' => 'Running Dynamics' }
69
+ type = rename[type] if rename.include?(type)
70
+ t.cell('Device Type:')
71
+ t.cell(type)
72
+ t.new_row
73
+ end
74
+ if device.serial_number
75
+ t.cell('Serial Number:')
76
+ t.cell(device.serial_number)
77
+ t.new_row
78
+ end
79
+ if device.software_version
80
+ t.cell('Software Version:')
81
+ t.cell(device.software_version)
82
+ t.new_row
83
+ end
84
+ if (rx_ok = device.rx_packets_ok) && (rx_err = device.rx_packets_err)
85
+ t.cell('Packet Errors:')
86
+ t.cell('%d%%' % ((rx_err.to_f / (rx_ok + rx_err)) * 100).to_i)
87
+ t.new_row
88
+ end
89
+ if device.battery_status
90
+ t.cell('Battery Status:')
91
+ t.cell(device.battery_status)
92
+ t.new_row
93
+ end
94
+
95
+ seen_indexes << device.device_index
96
+ end
97
+
98
+ tables.reverse
99
+ end
100
+
101
+ end
102
+
103
+ end
104
+