postrunner 0.0.11 → 0.1.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.
@@ -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
+