postrunner 0.0.11 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = FFS_Device.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015, 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 'perobs'
14
+ require 'postrunner/FFS_Activity'
15
+
16
+ module PostRunner
17
+
18
+ # Objects of this class can store the activities and monitoring data of a
19
+ # specific device. The device gets a random number assigned as a unique but
20
+ # anonymous ID. It also gets a long ID assigned that is a String of the
21
+ # manufacturer, the product name and the serial number concatenated by
22
+ # dashes. All objects are transparently stored in the PEROBS::Store.
23
+ class FFS_Device < PEROBS::Object
24
+
25
+ po_attr :activities, :monitorings, :short_uid, :long_uid
26
+
27
+ # Create a new FFS_Device object.
28
+ # @param p [PEROBS::Handle] p
29
+ # @param short_uid [Fixnum] A random number used a unique ID
30
+ # @param long_uid [String] A string consisting of the manufacturer and
31
+ # product name and the serial number.
32
+ def initialize(p, short_uid, long_uid)
33
+ super(p)
34
+ self.short_uid = short_uid
35
+ self.long_uid = long_uid
36
+ restore
37
+ end
38
+
39
+ # Handle initialization of persistent attributes.
40
+ def restore
41
+ attr_init(:activities) { @store.new(PEROBS::Array) }
42
+ attr_init(:monitorings) { @store.new(PEROBS::Array) }
43
+ end
44
+
45
+ # Add a new FIT file for this device.
46
+ # @param fit_file_name [String] The full path to the FIT file
47
+ # @param fit_entity [Fit4Ruby::FitEntity] The content of the FIT file
48
+ # @param overwrite [Boolean] A flag to indicate if an existing file should
49
+ # be replaced with the new one.
50
+ # @return [FFS_Activity or FFS_Monitoring] Corresponding entry in the
51
+ # FitFileStore or nil if file could not be added.
52
+ def add_fit_file(fit_file_name, fit_entity, overwrite)
53
+ case fit_entity.class
54
+ when Fit4Ruby::Activity.class
55
+ entity = activity_by_file_name(File.basename(fit_file_name))
56
+ entities = @activities
57
+ type = 'activity'
58
+ new_entity_class = FFS_Activity
59
+ when Fit4Ruby::Monitoring.class
60
+ entity = monitoring_by_file_name(File.basename(fit_file_name))
61
+ entities = @monitorings
62
+ type = 'monitoring'
63
+ new_entity_class = FFS_Monitoring
64
+ else
65
+ Log.fatal "Unsupported FIT entity #{fit_entity.class}"
66
+ end
67
+
68
+ if entity
69
+ if overwrite
70
+ # Replace the old file. All meta-information will be lost.
71
+ entities.delete_if { |e| e.fit_file_name == fit_file_name }
72
+ entity = @store.new(new_entity_class, myself, fit_file_name,
73
+ fit_entity)
74
+ else
75
+ Log.debug "FIT file #{fit_file_name} has already been imported"
76
+ # Refuse to replace the file.
77
+ return nil
78
+ end
79
+ else
80
+ # Don't add the entity if has deleted before and overwrite isn't true.
81
+ path = @store['file_store'].fit_file_dir(File.basename(fit_file_name),
82
+ long_uid, type)
83
+ fq_fit_file_name = File.join(path, File.basename(fit_file_name))
84
+ if File.exists?(fq_fit_file_name) && !overwrite
85
+ Log.debug "FIT file #{fq_fit_file_name} has already been imported " +
86
+ "and deleted"
87
+ return nil
88
+ end
89
+ # Add the new file to the list.
90
+ entity = @store.new(new_entity_class, myself, fit_file_name, fit_entity)
91
+ end
92
+ entity.store_fit_file(fit_file_name)
93
+ entities << entity
94
+ entities.sort!
95
+
96
+ # Scan the activity for any potential new personal records and register
97
+ # them.
98
+ if entity.is_a?(FFS_Activity)
99
+ records = @store['records']
100
+ records.scan_activity_for_records(entity, true)
101
+ end
102
+
103
+ entity
104
+ end
105
+
106
+ # Delete the given activity from the activity list.
107
+ # @param activity [FFS_Activity] activity to delete
108
+ def delete_activity(activity)
109
+ @activities.delete(activity)
110
+ end
111
+
112
+ # Return the activity with the given file name.
113
+ # @param file_name [String] Base name of the fit file.
114
+ # @return [FFS_Activity] Corresponding FFS_Activity or nil.
115
+ def activity_by_file_name(file_name)
116
+ @activities.find { |a| a.fit_file_name == file_name }
117
+ end
118
+
119
+ # Return the monitoring with the given file name.
120
+ # @param file_name [String] Base name of the fit file.
121
+ # @return [FFS_Activity] Corresponding FFS_Monitoring or nil.
122
+ def monitoring_by_file_name(file_name)
123
+ @monitorings.find { |a| a.fit_file_name == file_name }
124
+ end
125
+
126
+ end
127
+
128
+ end
129
+
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = FitFileStore.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014, 2015, 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
+ require 'postrunner/Log'
17
+ require 'postrunner/DirUtils'
18
+ require 'postrunner/FFS_Device'
19
+ require 'postrunner/ActivityListView'
20
+ require 'postrunner/ViewButtons'
21
+
22
+ module PostRunner
23
+
24
+ # The FitFileStore stores all FIT file and provides access to the contained
25
+ # data.
26
+ class FitFileStore < PEROBS::Object
27
+
28
+ include DirUtils
29
+
30
+ po_attr :devices
31
+
32
+ attr_reader :store, :views
33
+
34
+ # Create a new FIT file store.
35
+ # @param p [PEROBS::Handle] PEROBS handle
36
+ def initialize(p)
37
+ super(p)
38
+ restore
39
+ end
40
+
41
+ # Setup non-persistent variables.
42
+ def restore
43
+ @data_dir = @store['config']['data_dir']
44
+ # Ensure that we have an Array in the store to hold all known devices.
45
+ @store['devices'] = @store.new(PEROBS::Hash) unless @store['devices']
46
+
47
+ @devices_dir = File.join(@data_dir, 'devices')
48
+ # It's generally not a good idea to store absolute file names in the
49
+ # database. We'll make an exception here as this is the only way to
50
+ # propagate this path to FFS_Activity or FFS_Monitoring objects. The
51
+ # store entry is updated on each program run, so the DB can be moved
52
+ # safely to another directory.
53
+ @store['config']['devices_dir'] = @devices_dir
54
+ create_directory(@devices_dir, 'devices')
55
+
56
+ # Define which View objects the HTML output will consist of. This
57
+ # doesn't really belong in this class but for now it's the best place
58
+ # to put it.
59
+ @views = ViewButtons.new([
60
+ NavButtonDef.new('activities.png', 'index.html'),
61
+ NavButtonDef.new('record.png', "records-0.html")
62
+ ])
63
+ end
64
+
65
+ # Version upgrade logic.
66
+ def handle_version_update
67
+ # Nothing here so far.
68
+ end
69
+
70
+ # Add a file to the store.
71
+ # @param fit_file_name [String] Name of the FIT file
72
+ # @param overwrite [TrueClass, FalseClass] If true, an existing file will
73
+ # be replaced.
74
+ # @return [FFS_Activity or FFS_Monitoring] Corresponding entry in the
75
+ # FitFileStore or nil if file could not be added.
76
+ def add_fit_file(fit_file_name, fit_entity = nil, overwrite = false)
77
+ # If we the file hasn't been read yet, read it in as a
78
+ # Fit4Ruby::Activity or Fit4Ruby::Monitoring entity.
79
+ unless fit_entity
80
+ return nil unless (fit_entity = read_fit_file(fit_file_name))
81
+ end
82
+
83
+ unless [ Fit4Ruby::Activity,
84
+ Fit4Ruby::Monitoring ].include?(fit_entity.class)
85
+ Log.critical "Unsupported FIT file type #{fit_entity.class}"
86
+ end
87
+
88
+ # Generate a String that uniquely identifies the device that generated
89
+ # the FIT file.
90
+ id = extract_fit_file_id(fit_entity)
91
+ long_uid = "#{id[:manufacturer]}-#{id[:product]}-#{id[:serial_number]}"
92
+
93
+ # Make sure the device that created the FIT file is properly registered.
94
+ device = register_device(long_uid)
95
+ # Store the FIT entity with the device.
96
+ entity = device.add_fit_file(fit_file_name, fit_entity, overwrite)
97
+
98
+ # The FIT file might be already stored or invalid. In that case we
99
+ # abort this method.
100
+ return nil unless entity
101
+
102
+ if fit_entity.is_a?(Fit4Ruby::Activity)
103
+ @store['records'].scan_activity_for_records(entity)
104
+
105
+ # Generate HTML file for this activity.
106
+ entity.generate_html_report
107
+
108
+ # The HTML activity views contain links to their predecessors and
109
+ # successors. After inserting a new activity, we need to re-generate
110
+ # these views as well.
111
+ if (pred = predecessor(entity))
112
+ pred.generate_html_report
113
+ end
114
+ if (succ = successor(entity))
115
+ succ.generate_html_report
116
+ end
117
+ # And update the index pages
118
+ generate_html_index_pages
119
+ end
120
+
121
+ Log.info "#{File.basename(fit_file_name)} " +
122
+ 'has been successfully added to archive'
123
+
124
+ entity
125
+ end
126
+
127
+ # Delete an activity from the database. It will only delete the entry in
128
+ # the database. The original activity file will not be deleted from the
129
+ # file system.
130
+ # @param activity [FFS_Activity] Activity to delete
131
+ def delete_activity(activity)
132
+ pred = predecessor(activity)
133
+ succ = successor(activity)
134
+
135
+ activity.device.delete_activity(activity)
136
+
137
+ # The HTML activity views contain links to their predecessors and
138
+ # successors. After deleting an activity, we need to re-generate these
139
+ # views.
140
+ pred.generate_html_report if pred
141
+ succ.generate_html_report if succ
142
+
143
+ generate_html_index_pages
144
+ end
145
+
146
+ # Rename the specified activity and update all HTML pages that contain the
147
+ # name.
148
+ # @param activity [FFS_Activity] Activity to rename
149
+ # @param name [String] New name
150
+ def rename_activity(activity, name)
151
+ activity.set('name', name)
152
+ generate_html_index_pages
153
+ end
154
+
155
+ # Set the specified attribute of the given activity to a new value.
156
+ # @param activity [FFS_Activity] Activity to rename
157
+ # @param attribute [String] name of the attribute to change
158
+ # @param value [any] new value of the attribute
159
+ def set_activity_attribute(activity, attribute, value)
160
+ activity.set(attribute, value)
161
+ case attribute
162
+ when 'norecord', 'type'
163
+ # If we have changed a norecord setting or an activity type, we need
164
+ # to regenerate all reports and re-collect the record list since we
165
+ # don't know which Activity needs to replace the changed one.
166
+ check
167
+ end
168
+ generate_html_index_pages
169
+ end
170
+
171
+ # Perform the necessary report updates after the unit system has been
172
+ # changed.
173
+ def change_unit_system
174
+ # If we have changed the unit system we need to re-generate all HTML
175
+ # reports.
176
+ activities.each do |activity|
177
+ activity.generate_html_report
178
+ end
179
+ @store['records'].generate_html_reports
180
+ generate_html_index_pages
181
+ end
182
+ # Determine the right directory for the given FIT file. The resulting path
183
+ # looks something like /home/user/.postrunner/devices/garmin-fenix3-1234/
184
+ # activity/5A.
185
+ # @param fit_file_base_name [String] The base name of the fit file
186
+ # @param long_uid [String] the long UID of the device
187
+ # @param type [String] 'activity' or 'monitoring'
188
+ # @return [String] the full path name of the archived FIT file
189
+ def fit_file_dir(fit_file_base_name, long_uid, type)
190
+ # The first letter of the FIT file specifies the creation year.
191
+ # The second letter of the FIT file specifies the creation month.
192
+ File.join(@store['config']['devices_dir'],
193
+ long_uid, type, fit_file_base_name[0..1])
194
+ end
195
+
196
+
197
+
198
+ # @return [Array of FFS_Device] List of registered devices.
199
+ def devices
200
+ @store['devices']
201
+ end
202
+
203
+ # @return [Array of FFS_Activity] List of stored activities.
204
+ def activities
205
+ list = []
206
+ @store['devices'].each do |id, device|
207
+ list += device.activities
208
+ end
209
+ list.sort
210
+ end
211
+
212
+ # Return the reference index of the given FFS_Activity.
213
+ # @param activity [FFS_Activity]
214
+ # @return [Fixnum] Reference index as used in the UI
215
+ def ref_by_activity(activity)
216
+ return nil unless (idx = activities.index(activity))
217
+
218
+ idx + 1
219
+ end
220
+
221
+ # Return the next Activity after the provided activity. Note that this has
222
+ # a lower index. If none is found, return nil.
223
+ def successor(activity)
224
+ all_activities = activities
225
+ idx = all_activities.index(activity)
226
+ return nil if idx.nil? || idx == 0
227
+ all_activities[idx - 1]
228
+ end
229
+
230
+ # Return the previous Activity before the provided activity.
231
+ # If none is found, return nil.
232
+ def predecessor(activity)
233
+ all_activities = activities
234
+ idx = all_activities.index(activity)
235
+ return nil if idx.nil?
236
+ # Activities indexes are reversed. The predecessor has a higher index.
237
+ all_activities[idx + 1]
238
+ end
239
+
240
+ # Find a specific subset of the activities based on their index.
241
+ # @param query [String]
242
+ def find(query)
243
+ case query
244
+ when /\A-?\d+$\z/
245
+ index = query.to_i
246
+ # The UI counts the activities from 1 to N. Ruby counts from 0 -
247
+ # (N-1).
248
+ if index <= 0
249
+ Log.error 'Index must be larger than 0'
250
+ return []
251
+ end
252
+ # The UI counts the activities from 1 to N. Ruby counts from 0 -
253
+ # (N-1).
254
+ if (a = activities[index - 1])
255
+ return [ a ]
256
+ end
257
+ when /\A-?\d+--?\d+\z/
258
+ idxs = query.match(/(?<sidx>-?\d+)-(?<eidx>-?[0-9]+)/)
259
+ if (sidx = idxs['sidx'].to_i) <= 0
260
+ Log.error 'Start index must be larger than 0'
261
+ return []
262
+ end
263
+ if (eidx = idxs['eidx'].to_i) <= 0
264
+ Log.error 'End index must be larger than 0'
265
+ return []
266
+ end
267
+ if eidx < sidx
268
+ Log.error 'Start index must be smaller than end index'
269
+ return []
270
+ end
271
+ # The UI counts the activities from 1 to N. Ruby counts from 0 -
272
+ # (N-1).
273
+ unless (as = activities[(sidx - 1)..(eidx - 1)]).empty?
274
+ return as
275
+ end
276
+ else
277
+ Log.error "Invalid activity query: #{query}"
278
+ end
279
+
280
+ []
281
+ end
282
+
283
+ # This methods checks all stored FIT files for correctness, updates all
284
+ # indexes and re-generates all HTML reports.
285
+ def check
286
+ records = @store['records']
287
+ records.delete_all_records
288
+ activities.sort do |a1, a2|
289
+ a1.timestamp <=> a2.timestamp
290
+ end.each do |a|
291
+ a.check
292
+ records.scan_activity_for_records(a)
293
+ end
294
+ records.generate_html_reports
295
+ generate_html_index_pages
296
+ end
297
+
298
+ # Show the activity list in a web browser.
299
+ def show_list_in_browser
300
+ generate_html_index_pages
301
+ @store['records'].generate_html_reports
302
+ show_in_browser(File.join(@store['config']['html_dir'], 'index.html'))
303
+ end
304
+
305
+ def list_activities
306
+ puts ActivityListView.new(self).to_s
307
+ end
308
+
309
+ # Launch a web browser and show an HTML file.
310
+ # @param html_file [String] file name of the HTML file to show
311
+ def show_in_browser(html_file)
312
+ cmd = "#{ENV['BROWSER'] || 'firefox'} \"#{html_file}\" &"
313
+
314
+ unless system(cmd)
315
+ Log.fatal "Failed to execute the following shell command: #{$cmd}\n" +
316
+ "#{$!}"
317
+ end
318
+ end
319
+
320
+ private
321
+
322
+ def read_fit_file(fit_file_name)
323
+ begin
324
+ return Fit4Ruby.read(fit_file_name)
325
+ rescue Fit4Ruby::Error
326
+ Log.error $!
327
+ return nil
328
+ end
329
+ end
330
+
331
+ def extract_fit_file_id(fit_entity)
332
+ fit_entity.device_infos.each do |di|
333
+ if di.device_index == 0
334
+ return {
335
+ :manufacturer => di.manufacturer,
336
+ :product => di.garmin_product || di.product,
337
+ :serial_number => di.serial_number
338
+ }
339
+ end
340
+ end
341
+ Log.fatal "Fit entity has no device info for 0"
342
+ end
343
+
344
+ def register_device(long_uid)
345
+ unless @store['devices'].include?(long_uid)
346
+ Log.info "New device registered: #{long_uid}"
347
+
348
+ # Generate a unique ID for the device that does not allow any insight
349
+ # on the number of and type of managed devices.
350
+ begin
351
+ short_uid = rand(2**32)
352
+ end while @store['devices'].find { |luid, d| d.short_uid == short_uid }
353
+
354
+ @store['devices'][long_uid] =
355
+ @store.new(FFS_Device, short_uid, long_uid)
356
+
357
+ # Create the directory to store the FIT files of this device.
358
+ create_directory(File.join(@devices_dir, long_uid), long_uid)
359
+ end
360
+
361
+ @store['devices'][long_uid]
362
+ end
363
+
364
+ def generate_html_index_pages
365
+ # Ensure that HTML index is up-to-date.
366
+ ActivityListView.new(myself).update_index_pages
367
+ end
368
+
369
+ end
370
+
371
+ end
372
+