postrunner 0.0.4 → 0.0.5

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