postrunner 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/postrunner.rb +2 -2
- data/lib/postrunner/ActivitySummary.rb +5 -7
- data/lib/postrunner/DailySleepAnalyzer.rb +225 -0
- data/lib/postrunner/DeviceList.rb +10 -0
- data/lib/postrunner/FFS_Activity.rb +0 -4
- data/lib/postrunner/FFS_Device.rb +19 -3
- data/lib/postrunner/FFS_Monitoring.rb +96 -0
- data/lib/postrunner/FitFileStore.rb +76 -10
- data/lib/postrunner/Main.rb +82 -49
- data/lib/postrunner/SleepStatistics.rb +117 -0
- data/lib/postrunner/ViewTop.rb +1 -0
- data/lib/postrunner/version.rb +1 -1
- data/postrunner.gemspec +1 -1
- data/spec/PostRunner_spec.rb +45 -23
- data/spec/spec_helper.rb +4 -0
- data/tasks/gem.rake +1 -2
- metadata +8 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ea10a2814c3c25363117c69d85a1f5e0d4c07c2c
|
4
|
+
data.tar.gz: f0c72064a97183bfee97978e11a534bd9c2d5c5e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 644edbd1db0ad2fe562e74a2d09b61ad2664942b1b0aaf31647a96d948cb9c2eb5951fd79a07302d4d2099215f76189b20524071fdd5a1b5d46d340dad851c8a
|
7
|
+
data.tar.gz: b2d3a31c489a3bc506c4d3d809abc35f4eb059edcf94fee1a8b630b52dbdc3e9e580b94825209123890ca670dc6999f28074a1e618c05342935d14a7abd4c167
|
data/lib/postrunner.rb
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
#
|
4
4
|
# = postrunner.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
5
|
#
|
6
|
-
# Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org>
|
6
|
+
# Copyright (c) 2014, 2016 by Chris Schlaeger <cs@taskjuggler.org>
|
7
7
|
#
|
8
8
|
# This program is free software; you can redistribute it and/or modify
|
9
9
|
# it under the terms of version 2 of the GNU General Public License as
|
@@ -21,6 +21,6 @@ require 'postrunner/Main'
|
|
21
21
|
|
22
22
|
module PostRunner
|
23
23
|
|
24
|
-
Main.new(ARGV)
|
24
|
+
Main.new.main(ARGV)
|
25
25
|
|
26
26
|
end
|
@@ -75,14 +75,12 @@ module PostRunner
|
|
75
75
|
{ :metric => 'km', :statute => 'mi'}) ])
|
76
76
|
end
|
77
77
|
t.row([ 'Time:', secsToHMS(session.total_timer_time) ])
|
78
|
+
t.row([ 'Avg. Speed:',
|
79
|
+
local_value(session, 'avg_speed', '%.1f %s',
|
80
|
+
{ :metric => 'km/h', :statute => 'mph' }) ])
|
78
81
|
if @activity.sport == 'running' || @activity.sport == 'multisport'
|
79
82
|
t.row([ 'Avg. Pace:', pace(session, 'avg_speed') ])
|
80
83
|
end
|
81
|
-
if @activity.sport != 'running'
|
82
|
-
t.row([ 'Avg. Speed:',
|
83
|
-
local_value(session, 'avg_speed', '%.1f %s',
|
84
|
-
{ :metric => 'km/h', :statute => 'mph' }) ])
|
85
|
-
end
|
86
84
|
t.row([ 'Total Ascent:',
|
87
85
|
local_value(session, 'total_ascent', '%.0f %s',
|
88
86
|
{ :metric => 'm', :statute => 'ft' }) ])
|
@@ -117,8 +115,8 @@ module PostRunner
|
|
117
115
|
end
|
118
116
|
if @activity.sport == 'cycling'
|
119
117
|
t.row([ 'Avg. Cadence:',
|
120
|
-
session.
|
121
|
-
"#{(2 * session.
|
118
|
+
session.avg_cadence ?
|
119
|
+
"#{(2 * session.avg_cadence).round} rpm" : '-' ])
|
122
120
|
end
|
123
121
|
|
124
122
|
t.row([ 'Training Effect:', session.total_training_effect ?
|
@@ -0,0 +1,225 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = DailySleepAnalzyer.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 by Chris Schlaeger <cs@taskjuggler.org>
|
7
|
+
#
|
8
|
+
# This program is free software; you can redistribute it and/or modify
|
9
|
+
# it under the terms of version 2 of the GNU General Public License as
|
10
|
+
# published by the Free Software Foundation.
|
11
|
+
#
|
12
|
+
|
13
|
+
require 'fit4ruby'
|
14
|
+
|
15
|
+
module PostRunner
|
16
|
+
|
17
|
+
# This class extracts the sleep information from a set of monitoring files
|
18
|
+
# and determines when and how long the user was awake or had a light or deep
|
19
|
+
# sleep.
|
20
|
+
class DailySleepAnalyzer
|
21
|
+
|
22
|
+
# Utility class to store the interval of a sleep/wake phase.
|
23
|
+
class SleepInterval < Struct.new(:from_time, :to_time, :phase)
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_reader :sleep_intervals, :utc_offset,
|
27
|
+
:total_sleep, :deep_sleep, :light_sleep
|
28
|
+
|
29
|
+
# Create a new DailySleepAnalyzer object to analyze the given monitoring
|
30
|
+
# files.
|
31
|
+
# @param monitoring_files [Array] A set of Monitoring_B objects
|
32
|
+
# @param day [String] Day to analyze as YY-MM-DD string
|
33
|
+
def initialize(monitoring_files, day)
|
34
|
+
@noon_yesterday = @noon_today = @utc_offset = nil
|
35
|
+
@sleep_intervals = []
|
36
|
+
|
37
|
+
# Day as Time object. Midnight UTC.
|
38
|
+
day_as_time = Time.parse(day + "-00:00:00+00:00").gmtime
|
39
|
+
extract_data_from_monitor_files(monitoring_files, day_as_time)
|
40
|
+
fill_sleep_activity
|
41
|
+
smoothen_sleep_activity
|
42
|
+
analyze
|
43
|
+
trim_wake_periods_at_ends
|
44
|
+
calculate_totals
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def extract_utc_offset(monitoring_file)
|
50
|
+
# The monitoring files have a monitoring_info section that contains a
|
51
|
+
# timestamp in UTC and a local_time field for the same time in the local
|
52
|
+
# time. If any of that isn't present, we use an offset of 0.
|
53
|
+
if (mi = monitoring_file.monitoring_infos).nil? || mi.empty? ||
|
54
|
+
(localtime = mi[0].local_time).nil?
|
55
|
+
return 0
|
56
|
+
end
|
57
|
+
|
58
|
+
# Otherwise the delta in seconds between UTC and localtime is the
|
59
|
+
# offset.
|
60
|
+
localtime - mi[0].timestamp
|
61
|
+
end
|
62
|
+
|
63
|
+
def extract_data_from_monitor_files(monitoring_files, day)
|
64
|
+
# We use an Array with entries for every minute from noon yesterday to
|
65
|
+
# noon today.
|
66
|
+
@sleep_activity = Array.new(24 * 60, nil)
|
67
|
+
monitoring_files.each do |mf|
|
68
|
+
utc_offset = extract_utc_offset(mf)
|
69
|
+
# Noon (local time) the day before the requested day. The time object
|
70
|
+
# is UTC for the noon time in the local time zone.
|
71
|
+
noon_yesterday = day - 12 * 60 * 60 - utc_offset
|
72
|
+
# Noon (local time) of the current day
|
73
|
+
noon_today = day + 12 * 60 * 60 - utc_offset
|
74
|
+
|
75
|
+
mf.monitorings.each do |m|
|
76
|
+
# Ignore all entries outside our 24 hour window from noon the day
|
77
|
+
# before to noon the current day.
|
78
|
+
next if m.timestamp < noon_yesterday || m.timestamp >= noon_today
|
79
|
+
|
80
|
+
if @noon_yesterday.nil? && @noon_today.nil?
|
81
|
+
# The instance variables will only be set once we have found our
|
82
|
+
# first monitoring file that matches the requested day. We use the
|
83
|
+
# local time setting for this first file even if it changes in
|
84
|
+
# subsequent files.
|
85
|
+
@noon_yesterday = noon_yesterday
|
86
|
+
@noon_today = noon_today
|
87
|
+
@utc_offset = utc_offset
|
88
|
+
end
|
89
|
+
|
90
|
+
if (cati = m.current_activity_type_intensity)
|
91
|
+
activity_type = cati & 0x1F
|
92
|
+
|
93
|
+
# Compute the index in the @sleep_activity Array.
|
94
|
+
index = (m.timestamp - @noon_yesterday) / 60
|
95
|
+
if activity_type == 8
|
96
|
+
intensity = (cati >> 5) & 0x7
|
97
|
+
@sleep_activity[index] = intensity
|
98
|
+
else
|
99
|
+
@sleep_activity[index] = false
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
def fill_sleep_activity
|
108
|
+
current = nil
|
109
|
+
@sleep_activity = @sleep_activity.reverse.map do |v|
|
110
|
+
v.nil? ? current : current = v
|
111
|
+
end.reverse
|
112
|
+
|
113
|
+
if $DEBUG
|
114
|
+
File.open('sleep-data.csv', 'w') do |f|
|
115
|
+
f.puts 'Date;Value'
|
116
|
+
@sleep_activity.each_with_index do |v, i|
|
117
|
+
f.puts "#{@noon_yesterday + i * 60};#{v.is_a?(Fixnum) ? v : 8}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def smoothen_sleep_activity
|
124
|
+
window_size = 30
|
125
|
+
|
126
|
+
@smoothed_sleep_activity = Array.new(24 * 60, nil)
|
127
|
+
0.upto(24 * 60 - 1).each do |i|
|
128
|
+
window_start_idx = i - window_size
|
129
|
+
window_end_idx = i
|
130
|
+
sum = 0.0
|
131
|
+
(i - window_size + 1).upto(i).each do |j|
|
132
|
+
sum += j < 0 ? 8.0 :
|
133
|
+
@sleep_activity[j].is_a?(Fixnum) ? @sleep_activity[j] : 8
|
134
|
+
end
|
135
|
+
@smoothed_sleep_activity[i] = sum / window_size
|
136
|
+
end
|
137
|
+
|
138
|
+
if $DEBUG
|
139
|
+
File.open('smoothed-sleep-data.csv', 'w') do |f|
|
140
|
+
f.puts 'Date;Value'
|
141
|
+
@smoothed_sleep_activity.each_with_index do |v, i|
|
142
|
+
f.puts "#{@noon_yesterday + i * 60};#{v}"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def analyze
|
149
|
+
current_phase = :awake
|
150
|
+
current_phase_start = @noon_yesterday
|
151
|
+
@sleep_intervals = []
|
152
|
+
|
153
|
+
@smoothed_sleep_activity.each_with_index do |v, idx|
|
154
|
+
if v < 0.25
|
155
|
+
phase = :deep_sleep
|
156
|
+
elsif v < 1.5
|
157
|
+
phase = :light_sleep
|
158
|
+
else
|
159
|
+
phase = :awake
|
160
|
+
end
|
161
|
+
|
162
|
+
if current_phase != phase
|
163
|
+
t = @noon_yesterday + 60 * idx
|
164
|
+
@sleep_intervals << SleepInterval.new(current_phase_start, t,
|
165
|
+
current_phase)
|
166
|
+
current_phase = phase
|
167
|
+
current_phase_start = t
|
168
|
+
end
|
169
|
+
end
|
170
|
+
@sleep_intervals << SleepInterval.new(current_phase_start, @noon_today,
|
171
|
+
current_phase)
|
172
|
+
end
|
173
|
+
|
174
|
+
def trim_wake_periods_at_ends
|
175
|
+
first_deep_sleep_idx = last_deep_sleep_idx = nil
|
176
|
+
|
177
|
+
@sleep_intervals.each_with_index do |p, idx|
|
178
|
+
if p.phase == :deep_sleep ||
|
179
|
+
(p.phase == :light_sleep && ((p.to_time - p.from_time) > 15 * 60))
|
180
|
+
first_deep_sleep_idx = idx unless first_deep_sleep_idx
|
181
|
+
last_deep_sleep_idx = idx
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
return unless first_deep_sleep_idx && last_deep_sleep_idx
|
186
|
+
|
187
|
+
if first_deep_sleep_idx > 0 &&
|
188
|
+
@sleep_intervals[first_deep_sleep_idx - 1].phase == :light_sleep
|
189
|
+
first_deep_sleep_idx -= 1
|
190
|
+
end
|
191
|
+
if last_deep_sleep_idx < @sleep_intervals.length - 2 &&
|
192
|
+
@sleep_intervals[last_deep_sleep_idx + 1].phase == :light_sleep
|
193
|
+
last_deep_sleep_idx += 1
|
194
|
+
end
|
195
|
+
|
196
|
+
@sleep_intervals =
|
197
|
+
@sleep_intervals[first_deep_sleep_idx..last_deep_sleep_idx]
|
198
|
+
end
|
199
|
+
|
200
|
+
def calculate_totals
|
201
|
+
@total_sleep = @light_sleep = @deep_sleep = 0
|
202
|
+
@sleep_intervals.each do |p|
|
203
|
+
if p.phase != :awake
|
204
|
+
seconds = p.to_time - p.from_time
|
205
|
+
@total_sleep += seconds
|
206
|
+
if p.phase == :light_sleep
|
207
|
+
@light_sleep += seconds
|
208
|
+
else
|
209
|
+
@deep_sleep += seconds
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Return the begining of the current day in local time as Time object.
|
216
|
+
def begining_of_today(time = Time.now)
|
217
|
+
sec, min, hour, day, month, year = time.to_a
|
218
|
+
sec = min = hour = 0
|
219
|
+
Time.new(*[ year, month, day, hour, min, sec, 0 ]).localtime
|
220
|
+
end
|
221
|
+
|
222
|
+
end
|
223
|
+
|
224
|
+
end
|
225
|
+
|
@@ -31,6 +31,7 @@ module PostRunner
|
|
31
31
|
'environment_sensor_legacy' => 'GPS',
|
32
32
|
'gps' => 'GPS',
|
33
33
|
'heart_rate' => 'Heart Rate Sensor',
|
34
|
+
'optical_heart_rate' => 'Optical Heart Rate Sensor',
|
34
35
|
'running_dynamics' => 'Running Dynamics',
|
35
36
|
'stride_speed_distance' => 'Footpod'
|
36
37
|
}
|
@@ -104,6 +105,15 @@ module PostRunner
|
|
104
105
|
t.new_row
|
105
106
|
end
|
106
107
|
|
108
|
+
if type == 'GPS' && (epo = @fit_activity.epo_data) && epo.valid == 1
|
109
|
+
t.cell('EPO Data Start:')
|
110
|
+
t.cell(epo.interval_start)
|
111
|
+
t.new_row
|
112
|
+
t.cell('EPO Data End:')
|
113
|
+
t.cell(epo.interval_end)
|
114
|
+
t.new_row
|
115
|
+
end
|
116
|
+
|
107
117
|
if device.serial_number
|
108
118
|
t.cell('Serial Number:')
|
109
119
|
t.cell(device.serial_number)
|
@@ -12,6 +12,7 @@
|
|
12
12
|
|
13
13
|
require 'perobs'
|
14
14
|
require 'postrunner/FFS_Activity'
|
15
|
+
require 'postrunner/FFS_Monitoring'
|
15
16
|
|
16
17
|
module PostRunner
|
17
18
|
|
@@ -50,13 +51,12 @@ module PostRunner
|
|
50
51
|
# @return [FFS_Activity or FFS_Monitoring] Corresponding entry in the
|
51
52
|
# FitFileStore or nil if file could not be added.
|
52
53
|
def add_fit_file(fit_file_name, fit_entity, overwrite)
|
53
|
-
|
54
|
-
when Fit4Ruby::Activity.class
|
54
|
+
if fit_entity.is_a?(Fit4Ruby::Activity)
|
55
55
|
entity = activity_by_file_name(File.basename(fit_file_name))
|
56
56
|
entities = @activities
|
57
57
|
type = 'activity'
|
58
58
|
new_entity_class = FFS_Activity
|
59
|
-
|
59
|
+
elsif fit_entity.is_a?(Fit4Ruby::Monitoring_B)
|
60
60
|
entity = monitoring_by_file_name(File.basename(fit_file_name))
|
61
61
|
entities = @monitorings
|
62
62
|
type = 'monitoring'
|
@@ -123,6 +123,22 @@ module PostRunner
|
|
123
123
|
@monitorings.find { |a| a.fit_file_name == file_name }
|
124
124
|
end
|
125
125
|
|
126
|
+
# Return all monitorings that overlap with the time interval given by
|
127
|
+
# from_time and to_time.
|
128
|
+
# @param from_time [Time] start time of the interval
|
129
|
+
# @param to_time [Time] end time of the interval (not included)
|
130
|
+
# @return [Array] list of overlapping FFS_Monitoring objects.
|
131
|
+
def monitorings(from_time, to_time)
|
132
|
+
list = []
|
133
|
+
@monitorings.each do |m|
|
134
|
+
if (from_time <= m.period_start && m.period_start < to_time) ||
|
135
|
+
(from_time <= m.period_end && m.period_end < to_time)
|
136
|
+
list << m
|
137
|
+
end
|
138
|
+
end
|
139
|
+
list
|
140
|
+
end
|
141
|
+
|
126
142
|
end
|
127
143
|
|
128
144
|
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = FFS_Monitoring.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 by Chris Schlaeger <cs@taskjuggler.org>
|
7
|
+
#
|
8
|
+
# This program is free software; you can redistribute it and/or modify
|
9
|
+
# it under the terms of version 2 of the GNU General Public License as
|
10
|
+
# published by the Free Software Foundation.
|
11
|
+
#
|
12
|
+
|
13
|
+
require 'fit4ruby'
|
14
|
+
require 'perobs'
|
15
|
+
|
16
|
+
module PostRunner
|
17
|
+
|
18
|
+
# The FFS_Monitoring objects can store a reference to the FIT file data and
|
19
|
+
# caches some frequently used values.
|
20
|
+
class FFS_Monitoring < PEROBS::Object
|
21
|
+
|
22
|
+
include DirUtils
|
23
|
+
|
24
|
+
po_attr :device, :fit_file_name, :name, :period_start, :period_end
|
25
|
+
|
26
|
+
# Create a new FFS_Monitoring object.
|
27
|
+
# @param p [PEROBS::Handle] PEROBS handle
|
28
|
+
# @param fit_file_name [String] The fully qualified file name of the FIT
|
29
|
+
# file to add
|
30
|
+
# @param fit_entity [Fit4Ruby::FitEntity] The content of the loaded FIT
|
31
|
+
# file
|
32
|
+
def initialize(p, device, fit_file_name, fit_entity)
|
33
|
+
super(p)
|
34
|
+
|
35
|
+
self.device = device
|
36
|
+
self.fit_file_name = fit_file_name ? File.basename(fit_file_name) : nil
|
37
|
+
self.name = fit_file_name ? File.basename(fit_file_name) : nil
|
38
|
+
|
39
|
+
extract_summary_values(fit_entity)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Store a copy of the given FIT file in the corresponding directory.
|
43
|
+
# @param fit_file_name [String] Fully qualified name of the FIT file.
|
44
|
+
def store_fit_file(fit_file_name)
|
45
|
+
# Get the right target directory for this particular FIT file.
|
46
|
+
dir = @store['file_store'].fit_file_dir(File.basename(fit_file_name),
|
47
|
+
@device.long_uid, 'monitor')
|
48
|
+
# Create the necessary directories if they don't exist yet.
|
49
|
+
create_directory(dir, 'Device monitoring diretory')
|
50
|
+
|
51
|
+
# Copy the file into the target directory.
|
52
|
+
begin
|
53
|
+
FileUtils.cp(fit_file_name, dir)
|
54
|
+
rescue StandardError
|
55
|
+
Log.fatal "Cannot copy #{fit_file_name} into #{dir}: #{$!}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# FFS_Monitoring objects are sorted by their start time values and then by
|
60
|
+
# their device long_uids.
|
61
|
+
def <=>(a)
|
62
|
+
@period_start == a.period_start ?
|
63
|
+
a.device.long_uid <=> self.device.long_uid :
|
64
|
+
a.period_start <=> @period_start
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def extract_summary_values(fit_entity)
|
70
|
+
self.period_start = fit_entity.monitoring_infos[0].timestamp
|
71
|
+
|
72
|
+
period_end = @period_start
|
73
|
+
fit_entity.monitorings.each do |monitoring|
|
74
|
+
period_end = monitoring.timestamp if monitoring.timestamp
|
75
|
+
end
|
76
|
+
self.period_end = period_end
|
77
|
+
|
78
|
+
puts "#{@period_start} - #{@period_end}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def decode_activity_type(activity_type)
|
82
|
+
types = [ :generic, :running, :cycling, :transition,
|
83
|
+
:fitness_equipment, :swimming, :walking, :unknown7,
|
84
|
+
:resting, :unknown9 ]
|
85
|
+
if (decoded_type = types[activity_type])
|
86
|
+
decoded_type
|
87
|
+
else
|
88
|
+
Log.error "Unknown activity type #{activity_type}"
|
89
|
+
:generic
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
@@ -18,6 +18,7 @@ require 'postrunner/DirUtils'
|
|
18
18
|
require 'postrunner/FFS_Device'
|
19
19
|
require 'postrunner/ActivityListView'
|
20
20
|
require 'postrunner/ViewButtons'
|
21
|
+
require 'postrunner/SleepStatistics'
|
21
22
|
|
22
23
|
module PostRunner
|
23
24
|
|
@@ -81,8 +82,8 @@ module PostRunner
|
|
81
82
|
end
|
82
83
|
|
83
84
|
unless [ Fit4Ruby::Activity,
|
84
|
-
Fit4Ruby::
|
85
|
-
Log.
|
85
|
+
Fit4Ruby::Monitoring_B ].include?(fit_entity.class)
|
86
|
+
Log.fatal "Unsupported FIT file type #{fit_entity.class}"
|
86
87
|
end
|
87
88
|
|
88
89
|
# Generate a String that uniquely identifies the device that generated
|
@@ -318,6 +319,51 @@ module PostRunner
|
|
318
319
|
end
|
319
320
|
end
|
320
321
|
|
322
|
+
def daily_report(day)
|
323
|
+
monitorings = []
|
324
|
+
# 'day' specifies the current day. But we don't know what timezone the
|
325
|
+
# watch was set to for a given date. The files are always named after
|
326
|
+
# the moment of finishing the recording expressed as GMT time.
|
327
|
+
# Each file contains information about the time zone for the specific
|
328
|
+
# file. Recording is always flipped to a new file at midnight GMT but
|
329
|
+
# there are usually multiple files per GMT day.
|
330
|
+
day_as_time = Time.parse(day).gmtime
|
331
|
+
@store['devices'].each do |id, device|
|
332
|
+
# We are looking for all files that potentially overlap with our
|
333
|
+
# localtime day.
|
334
|
+
monitorings += device.monitorings(day_as_time - 36 * 60 * 60,
|
335
|
+
day_as_time + 36 * 60 * 60)
|
336
|
+
end
|
337
|
+
monitoring_files = monitorings.map do |m|
|
338
|
+
read_fit_file(File.join(fit_file_dir(m.fit_file_name, m.device.long_uid,
|
339
|
+
'monitor'), m.fit_file_name))
|
340
|
+
end
|
341
|
+
puts SleepStatistics.new(monitoring_files).daily(day)
|
342
|
+
end
|
343
|
+
|
344
|
+
def monthly_report(day)
|
345
|
+
monitorings = []
|
346
|
+
# 'day' specifies the current month. It must be in the form of
|
347
|
+
# YYYY-MM-01. But we don't know what timezone the watch was set to for a
|
348
|
+
# given date. The files are always named after the moment of finishing
|
349
|
+
# the recording expressed as GMT time. Each file contains information
|
350
|
+
# about the time zone for the specific file. Recording is always flipped
|
351
|
+
# to a new file at midnight GMT but there are usually multiple files per
|
352
|
+
# GMT day.
|
353
|
+
day_as_time = Time.parse(day).gmtime
|
354
|
+
@store['devices'].each do |id, device|
|
355
|
+
# We are looking for all files that potentially overlap with our
|
356
|
+
# localtime day.
|
357
|
+
monitorings += device.monitorings(day_as_time - 36 * 60 * 60,
|
358
|
+
day_as_time + 33 * 24 * 60 * 60)
|
359
|
+
end
|
360
|
+
monitoring_files = monitorings.map do |m|
|
361
|
+
read_fit_file(File.join(fit_file_dir(m.fit_file_name, m.device.long_uid,
|
362
|
+
'monitor'), m.fit_file_name))
|
363
|
+
end
|
364
|
+
puts SleepStatistics.new(monitoring_files).monthly(day)
|
365
|
+
end
|
366
|
+
|
321
367
|
private
|
322
368
|
|
323
369
|
def read_fit_file(fit_file_name)
|
@@ -330,16 +376,36 @@ module PostRunner
|
|
330
376
|
end
|
331
377
|
|
332
378
|
def extract_fit_file_id(fit_entity)
|
333
|
-
fit_entity.
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
379
|
+
unless (fid = fit_entity.file_id)
|
380
|
+
Log.fatal 'FIT file has no file_id section'
|
381
|
+
end
|
382
|
+
|
383
|
+
if fid.manufacturer == 'garmin' &&
|
384
|
+
fid.garmin_product == 'fr920xt'
|
385
|
+
# Garmin Fenix3 with firmware before 6.80 is reporting 'fr920xt' in
|
386
|
+
# the file_id section but 'fenix3' in the first device_info section.
|
387
|
+
# To tell the Fenix3 apart from the FR920XT we need to look into the
|
388
|
+
# device_info section for all devices with a garmin_product of
|
389
|
+
# 'fr920xt'.
|
390
|
+
fit_entity.device_infos.each do |di|
|
391
|
+
if di.device_index == 0
|
392
|
+
return {
|
393
|
+
:manufacturer => di.manufacturer,
|
394
|
+
:product => di.garmin_product || di.product,
|
395
|
+
:serial_number => di.serial_number
|
396
|
+
}
|
397
|
+
end
|
340
398
|
end
|
399
|
+
Log.fatal "Fit entity has no device info for 0"
|
400
|
+
else
|
401
|
+
# And for all properly developed devices we can just look at the
|
402
|
+
# file_id section.
|
403
|
+
return {
|
404
|
+
:manufacturer => fid.manufacturer,
|
405
|
+
:product => fid.garmin_product || fid.product,
|
406
|
+
:serial_number => fid.serial_number
|
407
|
+
}
|
341
408
|
end
|
342
|
-
Log.fatal "Fit entity has no device info for 0"
|
343
409
|
end
|
344
410
|
|
345
411
|
def register_device(long_uid)
|
data/lib/postrunner/Main.rb
CHANGED
@@ -30,15 +30,17 @@ module PostRunner
|
|
30
30
|
|
31
31
|
include DirUtils
|
32
32
|
|
33
|
-
def initialize
|
33
|
+
def initialize
|
34
34
|
@filter = nil
|
35
35
|
@name = nil
|
36
36
|
@force = false
|
37
37
|
@attribute = nil
|
38
38
|
@value = nil
|
39
39
|
@db_dir = File.join(ENV['HOME'], '.postrunner')
|
40
|
+
end
|
40
41
|
|
41
|
-
|
42
|
+
def main(args)
|
43
|
+
return 0 if (args = parse_options(args)).nil?
|
42
44
|
|
43
45
|
unless $DEBUG
|
44
46
|
Kernel.trap('INT') do
|
@@ -51,15 +53,40 @@ module PostRunner
|
|
51
53
|
end
|
52
54
|
|
53
55
|
begin
|
54
|
-
|
56
|
+
create_directory(@db_dir, 'PostRunner data')
|
57
|
+
@db = PEROBS::Store.new(File.join(@db_dir, 'database'))
|
58
|
+
# Create a hash to store configuration data in the store unless it
|
59
|
+
# exists already.
|
60
|
+
cfg = (@db['config'] ||= @db.new(PEROBS::Hash))
|
61
|
+
cfg['unit_system'] ||= :metric
|
62
|
+
cfg['version'] ||= VERSION
|
63
|
+
# We always override the data_dir as the user might have moved the data
|
64
|
+
# directory. The only reason we store it in the DB is to have it
|
65
|
+
# available throught the application.
|
66
|
+
cfg['data_dir'] = @db_dir
|
67
|
+
# Always update html_dir setting so that the DB directory can be moved
|
68
|
+
# around by the user.
|
69
|
+
cfg['html_dir'] = File.join(@db_dir, 'html')
|
70
|
+
|
71
|
+
setup_directories
|
72
|
+
if $DEBUG && (errors = @db.check) != 0
|
73
|
+
Log.abort "Postrunner database is corrupted: #{errors} errors found"
|
74
|
+
end
|
75
|
+
return execute_command(args)
|
76
|
+
|
55
77
|
rescue Exception => e
|
56
78
|
if e.is_a?(SystemExit) || e.is_a?(Interrupt)
|
57
79
|
$stderr.puts e.backtrace.join("\n") if $DEBUG
|
80
|
+
elsif e.is_a?(Fit4Ruby::Abort)
|
81
|
+
# Programm execution error that does not warrant a backtrace to be
|
82
|
+
# printed.
|
83
|
+
return -1
|
58
84
|
else
|
59
|
-
Log.
|
85
|
+
Log.error("#{e}\n#{e.backtrace.join("\n")}\n\n" +
|
60
86
|
"#{'*' * 79}\nYou have triggered a bug in PostRunner " +
|
61
87
|
"#{VERSION}!")
|
62
88
|
end
|
89
|
+
return -1
|
63
90
|
end
|
64
91
|
end
|
65
92
|
|
@@ -71,7 +98,7 @@ module PostRunner
|
|
71
98
|
|
72
99
|
opts.separator <<"EOT"
|
73
100
|
|
74
|
-
Copyright (c) 2014, 2015 by Chris Schlaeger
|
101
|
+
Copyright (c) 2014, 2015, 2016 by Chris Schlaeger
|
75
102
|
|
76
103
|
This program is free software; you can redistribute it and/or modify it under
|
77
104
|
the terms of version 2 of the GNU General Public License as published by the
|
@@ -157,12 +184,19 @@ import [ <fit file> | <directory> ]
|
|
157
184
|
file or directory is provided, the directory that was used for the
|
158
185
|
previous import is being used.
|
159
186
|
|
187
|
+
daily [ <date> ]
|
188
|
+
Print a report summarizing the current day or the specified day.
|
189
|
+
|
160
190
|
delete <ref>
|
161
191
|
Delete the activity from the archive.
|
162
192
|
|
163
193
|
list
|
164
194
|
List all FIT files stored in the data base.
|
165
195
|
|
196
|
+
monthly [ <date> ]
|
197
|
+
Print a table with various statistics for each day of the specified
|
198
|
+
month.
|
199
|
+
|
166
200
|
records
|
167
201
|
List all personal records.
|
168
202
|
|
@@ -217,35 +251,11 @@ EOT
|
|
217
251
|
begin
|
218
252
|
parser.parse!(args)
|
219
253
|
rescue OptionParser::InvalidOption
|
220
|
-
Log.
|
254
|
+
Log.error "#{$!}\n" + help
|
255
|
+
return nil
|
221
256
|
end
|
222
257
|
end
|
223
258
|
|
224
|
-
def main(args)
|
225
|
-
create_directory(@db_dir, 'PostRunner data')
|
226
|
-
@db = PEROBS::Store.new(File.join(@db_dir, 'database'))
|
227
|
-
# Create a hash to store configuration data in the store unless it
|
228
|
-
# exists already.
|
229
|
-
cfg = (@db['config'] ||= @db.new(PEROBS::Hash))
|
230
|
-
cfg['unit_system'] ||= :metric
|
231
|
-
cfg['version'] ||= VERSION
|
232
|
-
# We always override the data_dir as the user might have moved the data
|
233
|
-
# directory. The only reason we store it in the DB is to have it
|
234
|
-
# available throught the application.
|
235
|
-
cfg['data_dir'] = @db_dir
|
236
|
-
# Always update html_dir setting so that the DB directory can be moved
|
237
|
-
# around by the user.
|
238
|
-
cfg['html_dir'] = File.join(@db_dir, 'html')
|
239
|
-
|
240
|
-
setup_directories
|
241
|
-
if $DEBUG && (errors = @db.check) != 0
|
242
|
-
Log.fatal "Postrunner database is corrupted: #{errors} errors found"
|
243
|
-
end
|
244
|
-
execute_command(args)
|
245
|
-
|
246
|
-
@db.sync
|
247
|
-
end
|
248
|
-
|
249
259
|
def setup_directories
|
250
260
|
create_directory(@db['config']['html_dir'], 'HTML output')
|
251
261
|
|
@@ -255,18 +265,18 @@ EOT
|
|
255
265
|
misc_dir = File.realpath(File.join(File.dirname(__FILE__),
|
256
266
|
'..', '..', 'misc'))
|
257
267
|
unless Dir.exists?(misc_dir)
|
258
|
-
Log.
|
268
|
+
Log.abort "Cannot find 'misc' directory under '#{misc_dir}': #{$!}"
|
259
269
|
end
|
260
270
|
src_dir = File.join(misc_dir, dir)
|
261
271
|
unless Dir.exists?(src_dir)
|
262
|
-
Log.
|
272
|
+
Log.abort "Cannot find '#{src_dir}': #{$!}"
|
263
273
|
end
|
264
274
|
dst_dir = @db['config']['html_dir']
|
265
275
|
|
266
276
|
begin
|
267
277
|
FileUtils.cp_r(src_dir, dst_dir)
|
268
278
|
rescue IOError
|
269
|
-
Log.
|
279
|
+
Log.abort "Cannot copy auxilliary data directory '#{dst_dir}': #{$!}"
|
270
280
|
end
|
271
281
|
end
|
272
282
|
end
|
@@ -293,6 +303,14 @@ EOT
|
|
293
303
|
else
|
294
304
|
process_files_or_activities(args, :check)
|
295
305
|
end
|
306
|
+
when 'daily'
|
307
|
+
# Get the date of requested day in 'YY-MM-DD' format. If no argument
|
308
|
+
# is given, use the current date.
|
309
|
+
@ffs.daily_report(day_in_localtime(args, '%Y-%m-%d'))
|
310
|
+
when 'monthly'
|
311
|
+
# Get the date of requested day in 'YY-MM-DD' format. If no argument
|
312
|
+
# is given, use the current date.
|
313
|
+
@ffs.monthly_report(day_in_localtime(args, '%Y-%m-01'))
|
296
314
|
when 'delete'
|
297
315
|
process_activities(args, :delete)
|
298
316
|
when 'dump'
|
@@ -319,15 +337,15 @@ EOT
|
|
319
337
|
puts @records.to_s
|
320
338
|
when 'rename'
|
321
339
|
unless (@name = args.shift)
|
322
|
-
Log.
|
340
|
+
Log.abort 'You must provide a new name for the activity'
|
323
341
|
end
|
324
342
|
process_activities(args, :rename)
|
325
343
|
when 'set'
|
326
344
|
unless (@attribute = args.shift)
|
327
|
-
Log.
|
345
|
+
Log.abort 'You must specify the attribute you want to change'
|
328
346
|
end
|
329
347
|
unless (@value = args.shift)
|
330
|
-
Log.
|
348
|
+
Log.abort 'You must specify the new value for the attribute'
|
331
349
|
end
|
332
350
|
process_activities(args, :set)
|
333
351
|
when 'show'
|
@@ -347,12 +365,19 @@ EOT
|
|
347
365
|
when 'update-gps'
|
348
366
|
update_gps_data
|
349
367
|
when nil
|
350
|
-
Log.
|
351
|
-
"See 'postrunner -h' for more information.")
|
368
|
+
Log.abort("No command provided. " + help)
|
352
369
|
else
|
353
|
-
Log.
|
354
|
-
"See 'postrunner -h' for more information.")
|
370
|
+
Log.abort("Unknown command '#{cmd}'. " + help)
|
355
371
|
end
|
372
|
+
|
373
|
+
# Ensure that all updates are written to the database.
|
374
|
+
@db.sync
|
375
|
+
|
376
|
+
0
|
377
|
+
end
|
378
|
+
|
379
|
+
def help
|
380
|
+
"See 'postrunner -h' for more information."
|
356
381
|
end
|
357
382
|
|
358
383
|
def process_files_or_activities(files_or_activities, command)
|
@@ -367,7 +392,7 @@ EOT
|
|
367
392
|
|
368
393
|
def process_activities(activity_refs, command)
|
369
394
|
if activity_refs.empty?
|
370
|
-
Log.
|
395
|
+
Log.abort("You must provide at least one activity reference.")
|
371
396
|
end
|
372
397
|
|
373
398
|
activity_refs.each do |a_ref|
|
@@ -379,7 +404,7 @@ EOT
|
|
379
404
|
end
|
380
405
|
activities.each { |a| process_activity(a, command) }
|
381
406
|
else
|
382
|
-
Log.
|
407
|
+
Log.abort "Activity references must start with ':': #{a_ref}"
|
383
408
|
end
|
384
409
|
end
|
385
410
|
|
@@ -388,7 +413,7 @@ EOT
|
|
388
413
|
|
389
414
|
def process_files(files_or_dirs, command)
|
390
415
|
if files_or_dirs.empty?
|
391
|
-
Log.
|
416
|
+
Log.abort("You must provide at least one .FIT file name.")
|
392
417
|
end
|
393
418
|
|
394
419
|
files_or_dirs.each do |fod|
|
@@ -430,10 +455,9 @@ EOT
|
|
430
455
|
return false
|
431
456
|
end
|
432
457
|
|
433
|
-
if fit_entity.is_a?(Fit4Ruby::Activity)
|
458
|
+
if fit_entity.is_a?(Fit4Ruby::Activity) ||
|
459
|
+
fit_entity.is_a?(Fit4Ruby::Monitoring_B)
|
434
460
|
return @ffs.add_fit_file(fit_file_name, fit_entity, @force)
|
435
|
-
#elsif fit_entity.is_a?(Fit4Ruby::Monitoring_B)
|
436
|
-
# return @monitoring.add(fit_file_name, fit_entity)
|
437
461
|
else
|
438
462
|
Log.error "#{fit_file_name} is not a recognized FIT file"
|
439
463
|
return false
|
@@ -477,7 +501,7 @@ EOT
|
|
477
501
|
|
478
502
|
def change_unit_system(args)
|
479
503
|
if args.length != 1 || !%w( metric statute ).include?(args[0])
|
480
|
-
Log.
|
504
|
+
Log.error("You must specify 'metric' or 'statute' as unit system.")
|
481
505
|
end
|
482
506
|
|
483
507
|
if @db['config']['unit_system'].to_s != args[0]
|
@@ -488,7 +512,7 @@ EOT
|
|
488
512
|
|
489
513
|
def change_html_dir(args)
|
490
514
|
if args.length != 1
|
491
|
-
Log.
|
515
|
+
Log.error('You must specify a directory')
|
492
516
|
end
|
493
517
|
|
494
518
|
if @db['config']['html_dir'] != args[0]
|
@@ -530,6 +554,15 @@ EOT
|
|
530
554
|
end
|
531
555
|
end
|
532
556
|
|
557
|
+
def day_in_localtime(args, format)
|
558
|
+
begin
|
559
|
+
(args.empty? ? Time.now : Time.parse(args[0])).
|
560
|
+
localtime.strftime(format)
|
561
|
+
rescue ArgumentError
|
562
|
+
Log.abort("#{args[0]} is not a valid date. Use YYYY-MM-DD format.")
|
563
|
+
end
|
564
|
+
end
|
565
|
+
|
533
566
|
def handle_version_update
|
534
567
|
if @db['config']['version'] != VERSION
|
535
568
|
Log.warn "PostRunner version upgrade detected."
|
@@ -0,0 +1,117 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = SleepStatistics.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
|
+
#
|
6
|
+
# Copyright (c) 2016 by Chris Schlaeger <cs@taskjuggler.org>
|
7
|
+
#
|
8
|
+
# This program is free software; you can redistribute it and/or modify
|
9
|
+
# it under the terms of version 2 of the GNU General Public License as
|
10
|
+
# published by the Free Software Foundation.
|
11
|
+
#
|
12
|
+
|
13
|
+
require 'fit4ruby'
|
14
|
+
|
15
|
+
require 'postrunner/DailySleepAnalyzer'
|
16
|
+
require 'postrunner/FlexiTable'
|
17
|
+
|
18
|
+
module PostRunner
|
19
|
+
|
20
|
+
# This class can be used to generate reports for sleep data. It uses the
|
21
|
+
# DailySleepAnalyzer class to compute the data and generates the report for
|
22
|
+
# a certain time period.
|
23
|
+
class SleepStatistics
|
24
|
+
|
25
|
+
include Fit4Ruby::Converters
|
26
|
+
|
27
|
+
# Create a new SleepStatistics object.
|
28
|
+
# @param monitoring_files [Array of Fit4Ruby::Monitoring_B] FIT files
|
29
|
+
def initialize(monitoring_files)
|
30
|
+
@monitoring_files = monitoring_files
|
31
|
+
end
|
32
|
+
|
33
|
+
# Generate a report for a certain day.
|
34
|
+
# @param day [String] Date of the day as YYYY-MM-DD string.
|
35
|
+
def daily(day)
|
36
|
+
analyzer = DailySleepAnalyzer.new(@monitoring_files, day)
|
37
|
+
|
38
|
+
if analyzer.sleep_intervals.empty?
|
39
|
+
return 'No sleep data available for this day'
|
40
|
+
end
|
41
|
+
|
42
|
+
ti = FlexiTable.new
|
43
|
+
ti.head
|
44
|
+
ti.row([ 'From', 'To', 'Sleep phase' ])
|
45
|
+
ti.body
|
46
|
+
utc_offset = analyzer.utc_offset
|
47
|
+
analyzer.sleep_intervals.each do |i|
|
48
|
+
ti.cell(i[:from_time].localtime(utc_offset).strftime('%H:%M'))
|
49
|
+
ti.cell(i[:to_time].localtime(utc_offset).strftime('%H:%M'))
|
50
|
+
ti.cell(i[:phase])
|
51
|
+
ti.new_row
|
52
|
+
end
|
53
|
+
|
54
|
+
tt = FlexiTable.new
|
55
|
+
tt.head
|
56
|
+
tt.row([ 'Total Sleep', 'Deep Sleep', 'Light Sleep' ])
|
57
|
+
tt.body
|
58
|
+
tt.cell(secsToHM(analyzer.total_sleep), { :halign => :right })
|
59
|
+
tt.cell(secsToHM(analyzer.deep_sleep), { :halign => :right })
|
60
|
+
tt.cell(secsToHM(analyzer.light_sleep), { :halign => :right })
|
61
|
+
tt.new_row
|
62
|
+
|
63
|
+
"Sleep Statistics for #{day}\n\n#{ti}\n#{tt}"
|
64
|
+
end
|
65
|
+
|
66
|
+
def monthly(day)
|
67
|
+
day_as_time = Time.parse(day)
|
68
|
+
year = day_as_time.year
|
69
|
+
month = day_as_time.month
|
70
|
+
last_day_of_month = Date.new(year, month, -1).day
|
71
|
+
|
72
|
+
t = FlexiTable.new
|
73
|
+
left = { :halign => :left }
|
74
|
+
right = { :halign => :right }
|
75
|
+
t.set_column_attributes([ left, right, right, right ])
|
76
|
+
t.head
|
77
|
+
t.row([ 'Date', 'Total Sleep', 'Deep Sleep', 'Light Sleep' ])
|
78
|
+
t.body
|
79
|
+
totals = Hash.new(0)
|
80
|
+
counted_days = 0
|
81
|
+
|
82
|
+
1.upto(last_day_of_month).each do |dom|
|
83
|
+
day_str = Time.new(year, month, dom).strftime('%Y-%m-%d')
|
84
|
+
t.cell(day_str)
|
85
|
+
|
86
|
+
analyzer = DailySleepAnalyzer.new(@monitoring_files, day_str)
|
87
|
+
|
88
|
+
if analyzer.sleep_intervals.empty?
|
89
|
+
t.cell('-')
|
90
|
+
t.cell('-')
|
91
|
+
t.cell('-')
|
92
|
+
else
|
93
|
+
totals[:total_sleep] += analyzer.total_sleep
|
94
|
+
totals[:deep_sleep] += analyzer.deep_sleep
|
95
|
+
totals[:light_sleep] += analyzer.light_sleep
|
96
|
+
counted_days += 1
|
97
|
+
|
98
|
+
t.cell(secsToHM(analyzer.total_sleep))
|
99
|
+
t.cell(secsToHM(analyzer.deep_sleep))
|
100
|
+
t.cell(secsToHM(analyzer.light_sleep))
|
101
|
+
end
|
102
|
+
t.new_row
|
103
|
+
end
|
104
|
+
t.foot
|
105
|
+
t.cell('Averages')
|
106
|
+
t.cell(secsToHM(totals[:total_sleep] / counted_days))
|
107
|
+
t.cell(secsToHM(totals[:deep_sleep] / counted_days))
|
108
|
+
t.cell(secsToHM(totals[:light_sleep] / counted_days))
|
109
|
+
t.new_row
|
110
|
+
|
111
|
+
"Sleep Statistics for #{day_as_time.strftime('%B')} #{year}\n\n#{t}"
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
|
data/lib/postrunner/ViewTop.rb
CHANGED
data/lib/postrunner/version.rb
CHANGED
data/postrunner.gemspec
CHANGED
@@ -19,7 +19,7 @@ GEM_SPEC = 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
|
22
|
+
spec.add_dependency 'fit4ruby', '~> 1.0.0'
|
23
23
|
spec.add_dependency 'perobs', '~> 2.2'
|
24
24
|
spec.add_dependency 'nokogiri', '~> 1.6'
|
25
25
|
|
data/spec/PostRunner_spec.rb
CHANGED
@@ -19,13 +19,19 @@ describe PostRunner::Main do
|
|
19
19
|
|
20
20
|
def postrunner(args)
|
21
21
|
args = [ '--dbdir', @db_dir ] + args
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
22
|
+
begin
|
23
|
+
old_stdout = $stdout
|
24
|
+
old_stderr = $stderr
|
25
|
+
$stdout = (stdout = StringIO.new)
|
26
|
+
$stderr = (stderr = StringIO.new)
|
27
|
+
GC.start
|
28
|
+
retval = PostRunner::Main.new.main(args)
|
29
|
+
ensure
|
30
|
+
$stdout = old_stdout
|
31
|
+
$stderr = old_stderr
|
32
|
+
end
|
33
|
+
|
34
|
+
{ :retval => retval, :stdout => stdout.string, :stderr => stderr.string }
|
29
35
|
end
|
30
36
|
|
31
37
|
before(:all) do
|
@@ -47,11 +53,13 @@ describe PostRunner::Main do
|
|
47
53
|
end
|
48
54
|
|
49
55
|
it 'should abort without arguments' do
|
50
|
-
|
56
|
+
v = postrunner([])
|
57
|
+
expect(v[:retval]).to eql(-1)
|
51
58
|
end
|
52
59
|
|
53
60
|
it 'should abort with bad command' do
|
54
|
-
|
61
|
+
v = postrunner(%w( foobar))
|
62
|
+
expect(v[:retval]).to eql(-1)
|
55
63
|
end
|
56
64
|
|
57
65
|
it 'should support the -v option' do
|
@@ -79,51 +87,59 @@ describe PostRunner::Main do
|
|
79
87
|
end
|
80
88
|
|
81
89
|
it 'should list the imported file' do
|
82
|
-
|
90
|
+
v = postrunner(%w( list ))
|
91
|
+
expect(v[:stdout].index(File.basename(@file1))).to be_a(Fixnum)
|
83
92
|
end
|
84
93
|
|
85
94
|
it 'should import the 2nd FIT file' do
|
86
95
|
postrunner([ 'import', @file2 ])
|
87
|
-
|
96
|
+
v = postrunner(%w( list ))
|
97
|
+
list = v[:stdout]
|
88
98
|
expect(list.index(File.basename(@file1))).to be_a(Fixnum)
|
89
99
|
expect(list.index(File.basename(@file2))).to be_a(Fixnum)
|
90
100
|
end
|
91
101
|
|
92
102
|
it 'should delete the first file' do
|
93
103
|
postrunner(%w( delete :2 ))
|
94
|
-
|
104
|
+
v = postrunner(%w( list ))
|
105
|
+
list = v[:stdout]
|
95
106
|
expect(list.index(File.basename(@file1))).to be_nil
|
96
107
|
expect(list.index(File.basename(@file2))).to be_a(Fixnum)
|
97
108
|
end
|
98
109
|
|
99
110
|
it 'should not import the deleted file again' do
|
100
111
|
postrunner([ 'import', @file1 ])
|
101
|
-
|
112
|
+
v = postrunner(%w( list ))
|
113
|
+
list = v[:stdout]
|
102
114
|
expect(list.index(File.basename(@file1))).to be_nil
|
103
115
|
expect(list.index(File.basename(@file2))).to be_a(Fixnum)
|
104
116
|
end
|
105
117
|
|
106
118
|
it 'should rename FILE2.FIT activity' do
|
107
119
|
postrunner(%w( rename foobar :1 ))
|
108
|
-
|
120
|
+
v = postrunner(%w( list ))
|
121
|
+
list = v[:stdout]
|
109
122
|
expect(list.index(File.basename(@file2))).to be_nil
|
110
123
|
expect(list.index('foobar')).to be_a(Fixnum)
|
111
124
|
end
|
112
125
|
|
113
126
|
it 'should fail when setting bad attribute' do
|
114
|
-
|
127
|
+
v = postrunner(%w( set foo bar :1))
|
128
|
+
expect(v[:retval]).to eql(-1)
|
115
129
|
end
|
116
130
|
|
117
131
|
it 'should set name for 2nd activity' do
|
118
132
|
postrunner(%w( set name foobar :1 ))
|
119
|
-
|
133
|
+
v = postrunner(%w( list ))
|
134
|
+
list = v[:stdout]
|
120
135
|
expect(list.index(@file2)).to be_nil
|
121
136
|
expect(list.index('foobar')).to be_a(Fixnum)
|
122
137
|
end
|
123
138
|
|
124
139
|
it 'should set activity type for 2nd activity' do
|
125
140
|
postrunner(%w( set type Cycling :1 ))
|
126
|
-
|
141
|
+
v = postrunner(%w( summary :1 ))
|
142
|
+
list = v[:stdout]
|
127
143
|
expect(list.index('Running')).to be_nil
|
128
144
|
expect(list.index('Cycling')).to be_a(Fixnum)
|
129
145
|
end
|
@@ -137,18 +153,21 @@ describe PostRunner::Main do
|
|
137
153
|
end
|
138
154
|
|
139
155
|
it 'should fail when setting bad activity type' do
|
140
|
-
|
156
|
+
v = postrunner(%w( set type foobar :1))
|
157
|
+
expect(v[:retval]).to eql(-1)
|
141
158
|
end
|
142
159
|
|
143
160
|
it 'should set activity subtype for FILE2.FIT activity' do
|
144
161
|
postrunner(%w( set subtype Road :1 ))
|
145
|
-
|
162
|
+
v = postrunner(%w( summary :1 ))
|
163
|
+
list = v[:stdout]
|
146
164
|
expect(list.index('Generic')).to be_nil
|
147
165
|
expect(list.index('Road')).to be_a(Fixnum)
|
148
166
|
end
|
149
167
|
|
150
168
|
it 'should fail when setting bad activity subtype' do
|
151
|
-
|
169
|
+
v = postrunner(%w( set subtype foobar :1))
|
170
|
+
expect(v[:retval]).to eql(-1)
|
152
171
|
end
|
153
172
|
|
154
173
|
it 'should dump an activity from the archive' do
|
@@ -170,19 +189,22 @@ describe PostRunner::Main do
|
|
170
189
|
it 'should list records' do
|
171
190
|
# Add slow running activity
|
172
191
|
postrunner([ 'import', '--force', @file1 ])
|
173
|
-
|
192
|
+
v = postrunner([ 'records' ])
|
193
|
+
list = v[:stdout]
|
174
194
|
expect(list.index(File.basename(@file1))).to be_a(Fixnum)
|
175
195
|
|
176
196
|
# Add fast running activity
|
177
197
|
postrunner([ 'import', @file3 ])
|
178
|
-
|
198
|
+
v =postrunner([ 'records' ])
|
199
|
+
list = v[:stdout]
|
179
200
|
expect(list.index(File.basename(@file3))).to be_a(Fixnum)
|
180
201
|
expect(list.index(File.basename(@file1))).to be_nil
|
181
202
|
end
|
182
203
|
|
183
204
|
it 'should ignore records of an activity' do
|
184
205
|
postrunner(%w( set norecord true :1 ))
|
185
|
-
|
206
|
+
v = postrunner([ 'records' ])
|
207
|
+
list = v[:stdout]
|
186
208
|
expect(list.index(File.basename(@file1))).to be_a(Fixnum)
|
187
209
|
expect(list.index(File.basename(@file3))).to be_nil
|
188
210
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -80,6 +80,10 @@ def create_fit_activity(config)
|
|
80
80
|
ts = Time.parse(config[:t])
|
81
81
|
serial = config[:serial] || 12345890
|
82
82
|
a = Fit4Ruby::Activity.new({ :timestamp => ts })
|
83
|
+
a.new_file_id({ :manufacturer => 'garmin',
|
84
|
+
:time_created => ts,
|
85
|
+
:garmin_product => 'fr920xt',
|
86
|
+
:serial_number => serial })
|
83
87
|
a.total_timer_time = (config[:duration] || 10) * 60
|
84
88
|
a.new_user_profile({ :timestamp => ts,
|
85
89
|
:age => 33, :height => 1.78, :weight => 73.0,
|
data/tasks/gem.rake
CHANGED
@@ -28,8 +28,7 @@ task :permissions do
|
|
28
28
|
# Find the bin and test directories relative to this file.
|
29
29
|
baseDir = File.expand_path('..', File.dirname(__FILE__))
|
30
30
|
|
31
|
-
execs = Dir.glob("#{baseDir}/bin/*")
|
32
|
-
Dir.glob("#{baseDir}/test/**/genrefs")
|
31
|
+
execs = Dir.glob("#{baseDir}/bin/*")
|
33
32
|
|
34
33
|
Find.find(baseDir) do |f|
|
35
34
|
# Ignore the whoke pkg directory as it may contain links to the other
|
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.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Chris Schlaeger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-
|
11
|
+
date: 2016-04-17 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
|
19
|
+
version: 1.0.0
|
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
|
26
|
+
version: 1.0.0
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: perobs
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -135,6 +135,7 @@ files:
|
|
135
135
|
- lib/postrunner/ActivityView.rb
|
136
136
|
- lib/postrunner/BackedUpFile.rb
|
137
137
|
- lib/postrunner/ChartView.rb
|
138
|
+
- lib/postrunner/DailySleepAnalyzer.rb
|
138
139
|
- lib/postrunner/DataSources.rb
|
139
140
|
- lib/postrunner/DeviceList.rb
|
140
141
|
- lib/postrunner/DirUtils.rb
|
@@ -142,6 +143,7 @@ files:
|
|
142
143
|
- lib/postrunner/EventList.rb
|
143
144
|
- lib/postrunner/FFS_Activity.rb
|
144
145
|
- lib/postrunner/FFS_Device.rb
|
146
|
+
- lib/postrunner/FFS_Monitoring.rb
|
145
147
|
- lib/postrunner/FitFileStore.rb
|
146
148
|
- lib/postrunner/FlexiTable.rb
|
147
149
|
- lib/postrunner/HRV_Analyzer.rb
|
@@ -158,6 +160,7 @@ files:
|
|
158
160
|
- lib/postrunner/RecordListPageView.rb
|
159
161
|
- lib/postrunner/RuntimeConfig.rb
|
160
162
|
- lib/postrunner/Schema.rb
|
163
|
+
- lib/postrunner/SleepStatistics.rb
|
161
164
|
- lib/postrunner/TrackView.rb
|
162
165
|
- lib/postrunner/UserProfileView.rb
|
163
166
|
- lib/postrunner/View.rb
|
@@ -327,7 +330,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
327
330
|
version: '0'
|
328
331
|
requirements: []
|
329
332
|
rubyforge_project:
|
330
|
-
rubygems_version: 2.
|
333
|
+
rubygems_version: 2.2.2
|
331
334
|
signing_key:
|
332
335
|
specification_version: 4
|
333
336
|
summary: Application to manage and analyze Garmin FIT files.
|