mychron 0.3.2

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.
data/README.md ADDED
@@ -0,0 +1,413 @@
1
+ # MyChron
2
+
3
+ A Ruby gem for discovering and downloading telemetry data from AiM MyChron 6 kart data loggers over Wi-Fi.
4
+
5
+ ## Features
6
+
7
+ - **UDP Device Discovery** - Fast broadcast discovery of MyChron devices
8
+ - **Session Listing** - View all recorded sessions with lap times and metadata
9
+ - **File Download** - Download `.xrz` telemetry files with progress callbacks
10
+ - **Rails Integration** - Auto-detects Rails.logger, works seamlessly with Rails apps
11
+ - **Zero Dependencies** - Pure Ruby implementation using only standard library
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem 'mychron'
19
+ ```
20
+
21
+ Or install directly:
22
+
23
+ ```bash
24
+ gem install mychron
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ruby
30
+ require 'mychron'
31
+
32
+ # Discover devices on your network
33
+ devices = MyChron.discover
34
+ devices.each do |device|
35
+ puts "Found: #{device.device_name} at #{device.ip}"
36
+ end
37
+
38
+ # Work with a specific device
39
+ device = MyChron.device("192.168.1.29")
40
+
41
+ # List sessions
42
+ sessions = device.sessions
43
+ sessions.each do |session|
44
+ puts "#{session.filename} - #{session.laps} laps - #{session.best_time_formatted}"
45
+ end
46
+
47
+ # Download a file
48
+ data = device.download("a_0077.xrz")
49
+ File.binwrite("./telemetry/a_0077.xrz", data)
50
+ ```
51
+
52
+ ## API Reference
53
+
54
+ ### Discovery
55
+
56
+ ```ruby
57
+ # Broadcast discovery (find all devices)
58
+ devices = MyChron.discover
59
+ devices = MyChron.discover(timeout: 5.0) # Custom timeout
60
+
61
+ # Probe a specific IP
62
+ device_info = MyChron.find_device("192.168.1.29")
63
+ if device_info
64
+ puts device_info.device_name # => "JD-AT"
65
+ puts device_info.wifi_name # => "Wifi P2"
66
+ end
67
+ ```
68
+
69
+ ### Device Operations
70
+
71
+ ```ruby
72
+ device = MyChron.device("192.168.1.29")
73
+
74
+ # Check if device is reachable
75
+ device.reachable? # => true
76
+
77
+ # Get device info
78
+ info = device.info
79
+ puts info.device_name
80
+
81
+ # List all sessions
82
+ sessions = device.sessions
83
+
84
+ # Find a specific session
85
+ session = device.find_session("a_0077.xrz")
86
+
87
+ # Get the latest session
88
+ latest = device.latest_session
89
+ ```
90
+
91
+ ### Downloading
92
+
93
+ ```ruby
94
+ device = MyChron.device("192.168.1.29")
95
+
96
+ # Download to memory
97
+ data = device.download("a_0077.xrz")
98
+
99
+ # Download with progress callback
100
+ device.download("a_0077.xrz") do |received, total|
101
+ percent = (received.to_f / total * 100).round(1)
102
+ puts "Progress: #{percent}%"
103
+ end
104
+
105
+ # Download to a directory
106
+ path = device.download_to("a_0077.xrz", "./downloads")
107
+ # => "./downloads/a_0077.xrz"
108
+
109
+ # Download latest session
110
+ device.download_latest("./downloads")
111
+
112
+ # Download multiple files
113
+ paths = device.download_all(["a_0077.xrz", "a_0076.xrz"], "./downloads")
114
+
115
+ # Download only new sessions (skip existing)
116
+ new_paths = device.download_new("./downloads", skip_existing: true)
117
+ ```
118
+
119
+ ### Session Information
120
+
121
+ ```ruby
122
+ session = device.sessions.first
123
+
124
+ session.filename # => "a_0077.xrz"
125
+ session.size # => 524288
126
+ session.size_formatted # => "512.0 KB"
127
+ session.date # => "25/01/2026"
128
+ session.time # => "14:30:00"
129
+ session.recorded_at # => DateTime object
130
+ session.laps # => 5
131
+ session.best_lap # => 3
132
+ session.best_time_ms # => 65432
133
+ session.best_time_formatted # => "1:05.432"
134
+ session.driver # => "Driver Name"
135
+ session.to_h # => Hash representation
136
+ ```
137
+
138
+ ### Convenience Methods
139
+
140
+ ```ruby
141
+ # These create a temporary Device instance internally
142
+ sessions = MyChron.sessions("192.168.1.29")
143
+ data = MyChron.download("192.168.1.29", "a_0077.xrz")
144
+ path = MyChron.download_to("192.168.1.29", "a_0077.xrz", "./downloads")
145
+ ```
146
+
147
+ ## Configuration
148
+
149
+ ```ruby
150
+ MyChron.configure do |config|
151
+ # Default download directory
152
+ config.download_dir = "./downloads"
153
+
154
+ # Timeouts (seconds)
155
+ config.discovery_timeout = 2.0
156
+ config.connect_timeout = 5.0
157
+ config.read_timeout = 30.0
158
+ config.download_timeout = 300.0
159
+
160
+ # Debug mode
161
+ config.debug = true
162
+
163
+ # Custom logger
164
+ config.logger = Logger.new(STDOUT)
165
+ end
166
+ ```
167
+
168
+ ### Environment Variables
169
+
170
+ Configuration can also be set via environment variables:
171
+
172
+ ```bash
173
+ MYCHRON_DOWNLOAD_DIR=/path/to/downloads
174
+ MYCHRON_DISCOVERY_TIMEOUT=5.0
175
+ MYCHRON_DEBUG=true
176
+ ```
177
+
178
+ ## Rails Integration
179
+
180
+ ### Basic Setup
181
+
182
+ ```ruby
183
+ # config/initializers/mychron.rb
184
+ MyChron.configure do |config|
185
+ config.download_dir = Rails.root.join("storage/telemetry")
186
+ # Logger auto-detects Rails.logger, no need to set it
187
+ end
188
+ ```
189
+
190
+ ### Service Object Example
191
+
192
+ ```ruby
193
+ # app/services/telemetry_service.rb
194
+ class TelemetryService
195
+ def initialize(device_ip)
196
+ @device = MyChron.device(device_ip)
197
+ end
198
+
199
+ def sync_sessions
200
+ @device.sessions.each do |session|
201
+ TelemetrySession.find_or_create_by(filename: session.filename) do |record|
202
+ record.size = session.size
203
+ record.recorded_at = session.recorded_at
204
+ record.laps = session.laps
205
+ record.best_time_ms = session.best_time_ms
206
+ record.driver = session.driver
207
+ end
208
+ end
209
+ end
210
+
211
+ def download_session(filename)
212
+ destination = Rails.root.join("storage/telemetry")
213
+ @device.download_to(filename, destination)
214
+ end
215
+
216
+ def download_all_new
217
+ destination = Rails.root.join("storage/telemetry")
218
+ @device.download_new(destination)
219
+ end
220
+ end
221
+ ```
222
+
223
+ ### Background Job Example
224
+
225
+ ```ruby
226
+ # app/jobs/download_telemetry_job.rb
227
+ class DownloadTelemetryJob < ApplicationJob
228
+ queue_as :default
229
+
230
+ def perform(device_ip, filename)
231
+ device = MyChron.device(device_ip)
232
+ destination = Rails.root.join("storage/telemetry")
233
+
234
+ path = device.download_to(filename, destination) do |received, total|
235
+ # Optionally broadcast progress via ActionCable
236
+ percent = (received.to_f / total * 100).round
237
+ ActionCable.server.broadcast("downloads", { filename: filename, progress: percent })
238
+ end
239
+
240
+ # Update database record
241
+ TelemetrySession.find_by(filename: filename)&.update!(
242
+ downloaded: true,
243
+ local_path: path
244
+ )
245
+ end
246
+ end
247
+ ```
248
+
249
+ ### Controller Example
250
+
251
+ ```ruby
252
+ # app/controllers/telemetry_controller.rb
253
+ class TelemetryController < ApplicationController
254
+ def discover
255
+ devices = MyChron.discover
256
+ render json: devices.map { |d| { ip: d.ip, name: d.device_name } }
257
+ end
258
+
259
+ def sessions
260
+ device = MyChron.device(params[:device_ip])
261
+ sessions = device.sessions.map(&:to_h)
262
+ render json: sessions
263
+ end
264
+
265
+ def download
266
+ DownloadTelemetryJob.perform_later(params[:device_ip], params[:filename])
267
+ head :accepted
268
+ end
269
+ end
270
+ ```
271
+
272
+ ## Error Handling
273
+
274
+ ```ruby
275
+ begin
276
+ device = MyChron.device("192.168.1.29")
277
+ data = device.download("a_0077.xrz")
278
+ rescue MyChron::ConnectionError => e
279
+ # Device not reachable or connection refused
280
+ puts "Connection failed: #{e.message}"
281
+ rescue MyChron::DownloadError => e
282
+ # Download failed
283
+ puts "Download failed: #{e.message}"
284
+ rescue MyChron::NoSessionsError => e
285
+ # No sessions found when trying to download latest
286
+ puts "No sessions: #{e.message}"
287
+ rescue MyChron::Error => e
288
+ # Any other MyChron error
289
+ puts "Error: #{e.message}"
290
+ end
291
+ ```
292
+
293
+ ## Protocol Documentation
294
+
295
+ For detailed protocol information, see:
296
+ - [PROTOCOL.md](PROTOCOL.md) - Complete protocol specification
297
+
298
+ ## Device Setup
299
+
300
+ ### WiFi Configuration
301
+
302
+ The MyChron 6 must be in **WLAN mode** (joining your network) for this gem to work:
303
+
304
+ 1. On your MyChron, go to WiFi settings
305
+ 2. Select "WLAN" mode (not AP mode)
306
+ 3. Configure your network credentials
307
+ 4. Note the assigned IP address
308
+
309
+ ### Network Requirements
310
+
311
+ - MyChron and your application must be on the same subnet
312
+ - UDP port 36002 for device discovery
313
+ - TCP port 2000 for data transfer
314
+
315
+ ## Development & Testing
316
+
317
+ ### Interactive Console (IRB)
318
+
319
+ To test the gem interactively, start IRB with the library loaded:
320
+
321
+ ```bash
322
+ # From the gem directory
323
+ irb -I lib -r mychron
324
+
325
+ # Or use the one-liner
326
+ ruby -I lib -r mychron -e "binding.irb"
327
+ ```
328
+
329
+ Then you can experiment with the API:
330
+
331
+ ```ruby
332
+ # In IRB
333
+ MyChron::VERSION
334
+ # => "0.3.2"
335
+
336
+ # Discover devices (requires device on network)
337
+ devices = MyChron.discover
338
+ # => [#<struct MyChron::Protocol::Discovery::DeviceInfo ip="192.168.1.29", ...>]
339
+
340
+ # Create a device instance
341
+ device = MyChron.device("192.168.1.29")
342
+
343
+ # List sessions
344
+ sessions = device.sessions
345
+ sessions.each { |s| puts "#{s.filename} - #{s.laps} laps" }
346
+
347
+ # Download a file
348
+ data = device.download("a_0077.xrz")
349
+ puts "Downloaded #{data.bytesize} bytes"
350
+
351
+ # Check configuration
352
+ MyChron.configuration.to_h
353
+
354
+ # Enable debug logging
355
+ MyChron.configure { |c| c.logger = Logger.new(STDOUT) }
356
+ ```
357
+
358
+ ### Running with Bundler
359
+
360
+ If using Bundler:
361
+
362
+ ```bash
363
+ bundle exec irb -I lib -r mychron
364
+ ```
365
+
366
+ ### Quick Test Script
367
+
368
+ Create a test script to verify connectivity:
369
+
370
+ ```ruby
371
+ #!/usr/bin/env ruby
372
+ # test_device.rb
373
+ require_relative 'lib/mychron'
374
+
375
+ # Enable logging to see what's happening
376
+ MyChron.configure do |c|
377
+ c.logger = Logger.new(STDOUT)
378
+ end
379
+
380
+ puts "Discovering devices..."
381
+ devices = MyChron.discover(timeout: 3.0)
382
+
383
+ if devices.empty?
384
+ puts "No devices found. Make sure MyChron is on and connected to WiFi."
385
+ exit 1
386
+ end
387
+
388
+ device_info = devices.first
389
+ puts "Found: #{device_info.device_name} at #{device_info.ip}"
390
+
391
+ device = MyChron.device(device_info.ip)
392
+ sessions = device.sessions
393
+ puts "Sessions: #{sessions.count}"
394
+
395
+ sessions.first(5).each do |s|
396
+ puts " #{s.filename} - #{s.size_formatted} - #{s.laps} laps"
397
+ end
398
+ ```
399
+
400
+ Run it:
401
+
402
+ ```bash
403
+ ruby test_device.rb
404
+ ```
405
+
406
+ ## Requirements
407
+
408
+ - Ruby >= 3.0
409
+ - Network access to MyChron device
410
+
411
+ ## License
412
+
413
+ MIT
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ # Configuration for the MyChron gem
5
+ # Supports both programmatic configuration and environment variables
6
+ #
7
+ # @example Programmatic configuration
8
+ # MyChron.configure do |config|
9
+ # config.download_dir = Rails.root.join("storage/telemetry")
10
+ # config.discovery_timeout = 5.0
11
+ # config.logger = Rails.logger
12
+ # end
13
+ #
14
+ # @example Environment variables
15
+ # MYCHRON_DOWNLOAD_DIR=/path/to/downloads
16
+ # MYCHRON_DISCOVERY_TIMEOUT=5.0
17
+ # MYCHRON_DEBUG=true
18
+ #
19
+ class Configuration
20
+ DEFAULTS = {
21
+ # Directories
22
+ download_dir: "./downloads",
23
+
24
+ # Timeouts (in seconds)
25
+ discovery_timeout: 2.0,
26
+ connect_timeout: 5.0,
27
+ read_timeout: 30.0,
28
+ download_timeout: 300.0,
29
+
30
+ # Network scanning (for advanced discovery)
31
+ scan_ports: [2000, 21, 22, 80, 443, 8080, 5000, 6000, 7000, 9000] + (10_000..10_100).to_a,
32
+ scan_timeout: 0.5,
33
+ http_timeout: 5,
34
+ http_endpoints: ["/", "/api/", "/sessions/", "/data/", "/info"],
35
+
36
+ # Debugging
37
+ debug: false
38
+ }.freeze
39
+
40
+ # Directories
41
+ attr_accessor :download_dir
42
+
43
+ # Timeouts
44
+ attr_accessor :discovery_timeout, :connect_timeout, :read_timeout, :download_timeout
45
+
46
+ # Network scanning
47
+ attr_accessor :scan_ports, :scan_timeout, :http_timeout, :http_endpoints
48
+
49
+ # Debugging
50
+ attr_accessor :debug
51
+
52
+ # Custom logger (optional - defaults to Rails.logger or null logger)
53
+ attr_writer :logger
54
+
55
+ def initialize
56
+ DEFAULTS.each do |key, default_value|
57
+ env_value = env_value_for(key)
58
+ value = env_value.nil? ? default_value : coerce_value(env_value, default_value)
59
+ instance_variable_set("@#{key}", value)
60
+ end
61
+ end
62
+
63
+ # Get the configured logger
64
+ # @return [Logger] The logger instance
65
+ def logger
66
+ @logger || Logging.logger
67
+ end
68
+
69
+ # Check if debug mode is enabled
70
+ # @return [Boolean]
71
+ def debug?
72
+ @debug == true
73
+ end
74
+
75
+ # Convert configuration to a hash
76
+ # @return [Hash]
77
+ def to_h
78
+ DEFAULTS.keys.each_with_object({}) do |key, hash|
79
+ hash[key] = instance_variable_get("@#{key}")
80
+ end
81
+ end
82
+
83
+ # Reset configuration to defaults
84
+ def reset!
85
+ DEFAULTS.each do |key, value|
86
+ instance_variable_set("@#{key}", value)
87
+ end
88
+ @logger = nil
89
+ end
90
+
91
+ private
92
+
93
+ def env_value_for(key)
94
+ ENV["MYCHRON_#{key.to_s.upcase}"]
95
+ end
96
+
97
+ def coerce_value(env_value, default_value)
98
+ case default_value
99
+ when Float
100
+ env_value.to_f
101
+ when Integer
102
+ env_value.to_i
103
+ when TrueClass, FalseClass
104
+ %w[true 1 yes].include?(env_value.to_s.downcase)
105
+ when Array
106
+ env_value.split(",").map(&:strip)
107
+ else
108
+ env_value
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module MyChron
6
+ # High-level interface for interacting with a MyChron device
7
+ # Provides a clean API for device operations: discovery, session listing, downloads
8
+ #
9
+ # @example Basic usage
10
+ # device = MyChron::Device.new("192.168.1.29")
11
+ # sessions = device.sessions
12
+ # data = device.download("a_0077.xrz")
13
+ #
14
+ # @example With progress callback
15
+ # device.download("a_0077.xrz") do |received, total|
16
+ # puts "Progress: #{(received.to_f / total * 100).round(1)}%"
17
+ # end
18
+ #
19
+ class Device
20
+ include Logging
21
+
22
+ attr_reader :ip
23
+
24
+ # Create a new Device instance
25
+ # @param ip [String] IP address of the MyChron device
26
+ def initialize(ip)
27
+ @ip = ip
28
+ @client = nil
29
+ end
30
+
31
+ # Get device information via UDP probe
32
+ # @param timeout [Float] Timeout in seconds (default: 2.0)
33
+ # @return [DeviceInfo, nil] Device information or nil if not found
34
+ def info(timeout: 2.0)
35
+ discovery = Protocol::Discovery.new(timeout: timeout)
36
+ discovery.probe(@ip)
37
+ end
38
+
39
+ # Check if the device is reachable
40
+ # @param timeout [Float] Timeout in seconds (default: 2.0)
41
+ # @return [Boolean]
42
+ def reachable?(timeout: 2.0)
43
+ !info(timeout: timeout).nil?
44
+ rescue StandardError
45
+ false
46
+ end
47
+
48
+ # List all sessions on the device
49
+ # @return [Array<Session>] Array of Session objects
50
+ # @raise [ConnectionError] If unable to connect
51
+ # @raise [ProtocolError] If protocol communication fails
52
+ def sessions
53
+ with_client do |client|
54
+ raw_sessions = client.list_sessions
55
+ raw_sessions.map { |s| Session.new(s) }
56
+ end
57
+ end
58
+
59
+ # Find a specific session by filename
60
+ # @param filename [String] The session filename (e.g., "a_0077.xrz")
61
+ # @return [Session, nil] The session or nil if not found
62
+ def find_session(filename)
63
+ sessions.find { |s| s.filename == filename }
64
+ end
65
+
66
+ # Get the latest (most recent) session
67
+ # @return [Session, nil] The most recent session or nil if none
68
+ def latest_session
69
+ all_sessions = sessions
70
+ return nil if all_sessions.empty?
71
+
72
+ all_sessions.max_by do |session|
73
+ session.recorded_at || DateTime.new(1970, 1, 1)
74
+ end
75
+ end
76
+
77
+ # Download a specific session file
78
+ #
79
+ # @note Download uses a dedicated connection. The session listing protocol
80
+ # leaves the device in a different state, so downloads always use
81
+ # a fresh connection (handled internally by Client#download_session).
82
+ #
83
+ # @param filename [String] The session filename (e.g., "a_0077.xrz")
84
+ # @yield [bytes_received, total_bytes] Progress callback
85
+ # @return [String] Binary file data
86
+ # @raise [ConnectionError] If unable to connect
87
+ # @raise [DownloadError] If download fails
88
+ def download(filename, &progress_block)
89
+ log_info("Downloading #{filename} from #{@ip}")
90
+
91
+ # download_session handles its own connection lifecycle
92
+ # (always reconnects for clean state)
93
+ client = Protocol::Client.new(@ip)
94
+ begin
95
+ client.download_session(filename, &progress_block)
96
+ ensure
97
+ client.disconnect
98
+ end
99
+ rescue Protocol::ConnectionError => e
100
+ raise ConnectionError, e.message
101
+ rescue Protocol::ProtocolError => e
102
+ raise DownloadError, "Download failed: #{e.message}"
103
+ end
104
+
105
+ # Download a session file to a specific directory
106
+ # @param filename [String] The session filename
107
+ # @param destination_dir [String] Directory to save the file
108
+ # @yield [bytes_received, total_bytes] Progress callback
109
+ # @return [String] Full path to the saved file
110
+ def download_to(filename, destination_dir, &progress_block)
111
+ data = download(filename, &progress_block)
112
+
113
+ FileUtils.mkdir_p(destination_dir)
114
+ path = File.join(destination_dir, filename)
115
+ File.binwrite(path, data)
116
+
117
+ log_info("Saved #{filename} to #{path} (#{data.bytesize} bytes)")
118
+ path
119
+ end
120
+
121
+ # Download the latest session
122
+ # @param destination_dir [String, nil] Directory to save (returns data if nil)
123
+ # @yield [bytes_received, total_bytes] Progress callback
124
+ # @return [String] Binary data or path to saved file
125
+ # @raise [NoSessionsError] If no sessions found
126
+ def download_latest(destination_dir = nil, &progress_block)
127
+ session = latest_session
128
+ raise NoSessionsError, "No sessions found on device #{@ip}" unless session
129
+
130
+ if destination_dir
131
+ download_to(session.filename, destination_dir, &progress_block)
132
+ else
133
+ download(session.filename, &progress_block)
134
+ end
135
+ end
136
+
137
+ # Download multiple sessions
138
+ # @param filenames [Array<String>] List of filenames to download
139
+ # @param destination_dir [String] Directory to save files
140
+ # @yield [filename, bytes_received, total_bytes] Progress callback
141
+ # @return [Array<String>] Paths to saved files
142
+ def download_all(filenames, destination_dir, &progress_block)
143
+ filenames.map do |filename|
144
+ wrapped_block = if progress_block
145
+ ->(received, total) { progress_block.call(filename, received, total) }
146
+ end
147
+ download_to(filename, destination_dir, &wrapped_block)
148
+ end
149
+ end
150
+
151
+ # Download all sessions that haven't been downloaded yet
152
+ # @param destination_dir [String] Directory to save files
153
+ # @param skip_existing [Boolean] Skip files that already exist locally
154
+ # @yield [filename, bytes_received, total_bytes] Progress callback
155
+ # @return [Array<String>] Paths to newly downloaded files
156
+ def download_new(destination_dir, skip_existing: true, &progress_block)
157
+ downloaded = []
158
+
159
+ sessions.each do |session|
160
+ local_path = File.join(destination_dir, session.filename)
161
+
162
+ if skip_existing && File.exist?(local_path)
163
+ log_debug("Skipping #{session.filename} - already exists")
164
+ next
165
+ end
166
+
167
+ wrapped_block = if progress_block
168
+ ->(received, total) { progress_block.call(session.filename, received, total) }
169
+ end
170
+
171
+ path = download_to(session.filename, destination_dir, &wrapped_block)
172
+ downloaded << path
173
+ end
174
+
175
+ downloaded
176
+ end
177
+
178
+ def inspect
179
+ "#<MyChron::Device ip=#{@ip.inspect}>"
180
+ end
181
+
182
+ def to_s
183
+ "MyChron Device at #{@ip}"
184
+ end
185
+
186
+ private
187
+
188
+ def with_client
189
+ client = Protocol::Client.new(@ip)
190
+ client.connect
191
+ yield client
192
+ ensure
193
+ client&.disconnect
194
+ end
195
+ end
196
+ end