postrunner 0.2.1 → 0.3.0
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 +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.
|