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.
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module MyChron
6
+ module Protocol
7
+ # UDP Discovery protocol for finding MyChron devices
8
+ # Sends "aim-ka" beacon on port 36002, receives 236-byte device info
9
+ class Discovery
10
+ include Logging
11
+
12
+ DISCOVERY_PORT = 36002
13
+ BEACON = "aim-ka"
14
+ RESPONSE_SIZE = 236
15
+ DEFAULT_TIMEOUT = 2.0
16
+
17
+ DeviceInfo = Struct.new(
18
+ :ip,
19
+ :device_name,
20
+ :wifi_name,
21
+ :serial_number,
22
+ :firmware_version,
23
+ :raw_data,
24
+ keyword_init: true
25
+ )
26
+
27
+ def initialize(timeout: DEFAULT_TIMEOUT)
28
+ @timeout = timeout
29
+ end
30
+
31
+ # Discover devices via UDP broadcast
32
+ # Returns array of DeviceInfo
33
+ def discover(target_ip: nil, broadcast: true)
34
+ devices = []
35
+ socket = UDPSocket.new
36
+
37
+ begin
38
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) if broadcast
39
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
40
+ socket.bind("0.0.0.0", 0)
41
+
42
+ # Send beacon
43
+ begin
44
+ if target_ip
45
+ log_debug("Sending beacon to #{target_ip}:#{DISCOVERY_PORT}")
46
+ socket.send(BEACON, 0, target_ip, DISCOVERY_PORT)
47
+ else
48
+ log_debug("Sending broadcast beacon to 255.255.255.255:#{DISCOVERY_PORT}")
49
+ socket.send(BEACON, 0, "255.255.255.255", DISCOVERY_PORT)
50
+ end
51
+ rescue Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL => e
52
+ log_warn("Network error sending beacon: #{e.message}")
53
+ return devices
54
+ end
55
+
56
+ # Wait for responses
57
+ deadline = Time.now + @timeout
58
+ while Time.now < deadline
59
+ remaining = deadline - Time.now
60
+ break if remaining <= 0
61
+
62
+ readable = IO.select([socket], nil, nil, [remaining, 0.5].min)
63
+ next unless readable
64
+
65
+ begin
66
+ data, addr = socket.recvfrom_nonblock(RESPONSE_SIZE + 100)
67
+ sender_ip = addr[3]
68
+
69
+ if data.bytesize >= RESPONSE_SIZE
70
+ device = parse_response(data, sender_ip)
71
+ devices << device if device
72
+ log_info("Found device: #{device.device_name} at #{sender_ip}")
73
+ end
74
+ rescue IO::WaitReadable
75
+ # No data yet, continue
76
+ end
77
+ end
78
+ ensure
79
+ socket.close
80
+ end
81
+
82
+ devices
83
+ end
84
+
85
+ # Probe a specific IP address
86
+ def probe(ip)
87
+ devices = discover(target_ip: ip, broadcast: false)
88
+ devices.first
89
+ end
90
+
91
+ private
92
+
93
+ def parse_response(data, ip)
94
+ # Response structure (236 bytes) - verified from actual device capture:
95
+ # Offset 0x00 (0): Magic (0xEC000000)
96
+ # Offset 0x04 (4): Protocol version (0x02000000)
97
+ # Offset 0x08 (8): IP address (little-endian)
98
+ # Offset 0x0C (12): Flags/status
99
+ # Offset 0x14 (20): Device name (null-terminated string)
100
+ # Offset 0x54 (84): "idn" marker + version byte
101
+ # Offset 0x58 (88): Firmware version components (3x uint16 LE)
102
+ # Offset 0x60 (96): Serial number (uint32 LE)
103
+ # Offset 0x94 (148): WiFi config name (null-terminated string)
104
+
105
+ # Extract device name at offset 0x14 (20 decimal)
106
+ device_name = extract_string(data, 0x14, 48)
107
+
108
+ # Extract WiFi name at offset 0x94 (148 decimal)
109
+ wifi_name = extract_string(data, 0x94, 48)
110
+
111
+ # Extract serial number at offset 0x60 (96 decimal) - uint32 little-endian
112
+ serial_number = extract_serial_number(data, 0x60)
113
+
114
+ # Extract firmware version from offset 0x58 (88 decimal) - 3x uint16 LE
115
+ firmware_version = extract_firmware_version(data, 0x58)
116
+
117
+ DeviceInfo.new(
118
+ ip: ip,
119
+ device_name: device_name,
120
+ wifi_name: wifi_name,
121
+ serial_number: serial_number,
122
+ firmware_version: firmware_version,
123
+ raw_data: data
124
+ )
125
+ rescue StandardError => e
126
+ log_warn("Failed to parse response from #{ip}: #{e.message}")
127
+ nil
128
+ end
129
+
130
+ def extract_string(data, offset, max_length)
131
+ return "" if offset >= data.bytesize
132
+
133
+ str_bytes = data.byteslice(offset, max_length)
134
+ return "" if str_bytes.nil? || str_bytes.empty?
135
+
136
+ # Find null terminator
137
+ null_idx = str_bytes.index("\x00")
138
+ str = null_idx ? str_bytes[0...null_idx] : str_bytes
139
+
140
+ # Clean up - remove non-printable chars
141
+ str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
142
+ .gsub(/[^[:print:]]/, "")
143
+ .strip
144
+ end
145
+
146
+ def extract_serial_number(data, offset)
147
+ # Serial number is a 32-bit little-endian unsigned integer
148
+ return nil if offset + 4 > data.bytesize
149
+
150
+ data.byteslice(offset, 4).unpack1("V")
151
+ end
152
+
153
+ def extract_firmware_version(data, offset)
154
+ # Firmware version is 3x uint16 little-endian values
155
+ # Format appears to be: major.minor.patch or similar
156
+ return nil if offset + 6 > data.bytesize
157
+
158
+ v1, v2, v3 = data.byteslice(offset, 6).unpack("v3")
159
+ "#{v1}.#{v2}.#{v3}"
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module MyChron
6
+ # Represents a telemetry session recorded on a MyChron device
7
+ # Wraps the raw SessionInfo struct with convenience methods
8
+ class Session
9
+ attr_reader :filename, :size, :date, :time, :laps, :best_lap,
10
+ :best_time_ms, :driver, :device_name, :speed_type,
11
+ :status, :stop_mode, :timestamp, :longitude, :latitude, :duration
12
+
13
+ # Create a Session from a Protocol::Client::SessionInfo struct
14
+ # @param session_info [Protocol::Client::SessionInfo] Raw session data
15
+ def initialize(session_info)
16
+ @filename = session_info.filename
17
+ @size = session_info.size || 0
18
+ @date = session_info.date
19
+ @time = session_info.time
20
+ @laps = session_info.laps || 0
21
+ @best_lap = session_info.best_lap
22
+ @best_time_ms = session_info.best_time
23
+ @driver = session_info.driver
24
+ @device_name = session_info.device_name
25
+ @speed_type = session_info.speed_type
26
+ @status = session_info.status
27
+ @stop_mode = session_info.stop_mode
28
+ @timestamp = session_info.timestamp
29
+ @longitude = session_info.longitude
30
+ @latitude = session_info.latitude
31
+ @duration = session_info.duration
32
+ end
33
+
34
+ # Parse the recording date and time into a DateTime object
35
+ # @return [DateTime, nil] The recording timestamp
36
+ def recorded_at
37
+ return nil unless @date && @time
38
+
39
+ DateTime.strptime("#{@date} #{@time}", "%d/%m/%Y %H:%M:%S")
40
+ rescue Date::Error
41
+ nil
42
+ end
43
+
44
+ # Format the best lap time as "M:SS.mmm"
45
+ # @return [String, nil] Formatted time or nil if not available
46
+ def best_time_formatted
47
+ return nil unless @best_time_ms&.positive?
48
+
49
+ total_ms = @best_time_ms
50
+ minutes = total_ms / 60_000
51
+ remaining_ms = total_ms % 60_000
52
+ seconds = remaining_ms / 1000
53
+ milliseconds = remaining_ms % 1000
54
+
55
+ format("%d:%02d.%03d", minutes, seconds, milliseconds)
56
+ end
57
+
58
+ # Format file size in human-readable format
59
+ # @return [String] Formatted size (e.g., "512.5 KB")
60
+ def size_formatted
61
+ return "0 B" if @size.zero?
62
+
63
+ units = %w[B KB MB GB]
64
+ exp = (Math.log(@size) / Math.log(1024)).to_i
65
+ exp = [exp, units.length - 1].min
66
+ format("%.1f %s", @size.to_f / (1024**exp), units[exp])
67
+ end
68
+
69
+ # Check if this is a valid telemetry file
70
+ # @return [Boolean]
71
+ def valid?
72
+ filename&.match?(/\.(xrz|xrk|hrz|drk)$/i)
73
+ end
74
+
75
+ # Get the file extension
76
+ # @return [String, nil]
77
+ def extension
78
+ File.extname(@filename).downcase if @filename
79
+ end
80
+
81
+ # Duration in seconds (if available)
82
+ # @return [Float, nil]
83
+ def duration_seconds
84
+ @duration.to_f / 1000 if @duration&.positive?
85
+ end
86
+
87
+ # Hash representation for serialization
88
+ # @return [Hash]
89
+ def to_h
90
+ {
91
+ filename: @filename,
92
+ size: @size,
93
+ date: @date,
94
+ time: @time,
95
+ laps: @laps,
96
+ best_lap: @best_lap,
97
+ best_time_ms: @best_time_ms,
98
+ driver: @driver,
99
+ device_name: @device_name,
100
+ recorded_at: recorded_at&.iso8601
101
+ }
102
+ end
103
+
104
+ # JSON representation
105
+ # @return [String]
106
+ def to_json(*args)
107
+ to_h.to_json(*args)
108
+ end
109
+
110
+ def inspect
111
+ "#<MyChron::Session #{@filename} (#{size_formatted}, #{@laps} laps)>"
112
+ end
113
+
114
+ def to_s
115
+ "#{@filename} - #{size_formatted} - #{@laps} laps"
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ VERSION = "0.3.2"
5
+ end
data/lib/mychron.rb ADDED
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "timeout"
6
+ require "ipaddr"
7
+ require "open3"
8
+ require "time"
9
+ require "fileutils"
10
+
11
+ # Core modules
12
+ require_relative "mychron/version"
13
+ require_relative "mychron/errors"
14
+ require_relative "mychron/logging"
15
+ require_relative "mychron/configuration"
16
+ require_relative "mychron/session"
17
+ require_relative "mychron/device"
18
+
19
+ # Protocol implementation
20
+ require_relative "mychron/protocol/discovery"
21
+ require_relative "mychron/protocol/client"
22
+
23
+ # Network utilities (for advanced discovery)
24
+ require_relative "mychron/network/arp"
25
+ require_relative "mychron/network/scanner"
26
+ require_relative "mychron/network/http_probe"
27
+
28
+ # Discovery orchestration
29
+ require_relative "mychron/discovery/scorer"
30
+ require_relative "mychron/discovery/detector"
31
+
32
+ # MyChron - Ruby library for AiM MyChron 6 telemetry devices
33
+ #
34
+ # This gem provides a clean API for:
35
+ # - Discovering MyChron devices on your network
36
+ # - Listing recorded telemetry sessions
37
+ # - Downloading session files (.xrz format)
38
+ #
39
+ # @example Basic discovery and download
40
+ # # Find devices on the network
41
+ # devices = MyChron.discover
42
+ # puts "Found #{devices.count} device(s)"
43
+ #
44
+ # # Work with a specific device
45
+ # device = MyChron.device("192.168.1.29")
46
+ # sessions = device.sessions
47
+ # data = device.download(sessions.first.filename)
48
+ #
49
+ # @example Rails integration
50
+ # # In an initializer or controller
51
+ # MyChron.configure do |config|
52
+ # config.download_dir = Rails.root.join("storage/telemetry")
53
+ # config.logger = Rails.logger
54
+ # end
55
+ #
56
+ # # In a service or job
57
+ # device = MyChron.device(params[:device_ip])
58
+ # sessions = device.sessions
59
+ #
60
+ module MyChron
61
+ class << self
62
+ # Configure the MyChron gem
63
+ # @yield [Configuration] Configuration object
64
+ # @return [Configuration]
65
+ #
66
+ # @example
67
+ # MyChron.configure do |config|
68
+ # config.download_dir = "/path/to/downloads"
69
+ # config.discovery_timeout = 5.0
70
+ # config.logger = Rails.logger
71
+ # end
72
+ def configure
73
+ yield(configuration) if block_given?
74
+ configuration
75
+ end
76
+
77
+ # Get the current configuration
78
+ # @return [Configuration]
79
+ def configuration
80
+ @configuration ||= Configuration.new
81
+ end
82
+ alias config configuration
83
+
84
+ # Get the logger
85
+ # @return [Logger]
86
+ def logger
87
+ configuration.logger
88
+ end
89
+
90
+ # Set a custom logger
91
+ # @param logger [Logger] A Logger-compatible instance
92
+ def logger=(logger)
93
+ Logging.logger = logger
94
+ end
95
+
96
+ # Reset configuration to defaults
97
+ def reset!
98
+ @configuration = nil
99
+ Logging.reset!
100
+ end
101
+
102
+ # ----- Primary API: Device Discovery -----
103
+
104
+ # Discover all MyChron devices on the network via UDP broadcast
105
+ # @param timeout [Float] Timeout in seconds (default: 2.0)
106
+ # @return [Array<Protocol::Discovery::DeviceInfo>] Array of discovered devices
107
+ #
108
+ # @example
109
+ # devices = MyChron.discover
110
+ # devices.each do |device|
111
+ # puts "Found: #{device.device_name} at #{device.ip}"
112
+ # end
113
+ def discover(timeout: nil)
114
+ timeout ||= configuration.discovery_timeout
115
+ Protocol::Discovery.new(timeout: timeout).discover
116
+ end
117
+
118
+ # Probe a specific IP address to check for a MyChron device
119
+ # @param ip [String] IP address to probe
120
+ # @param timeout [Float] Timeout in seconds (default: 2.0)
121
+ # @return [Protocol::Discovery::DeviceInfo, nil] Device info or nil if not found
122
+ #
123
+ # @example
124
+ # device_info = MyChron.find_device("192.168.1.29")
125
+ # if device_info
126
+ # puts "Found: #{device_info.device_name}"
127
+ # end
128
+ def find_device(ip, timeout: nil)
129
+ timeout ||= configuration.discovery_timeout
130
+ Protocol::Discovery.new(timeout: timeout).probe(ip)
131
+ end
132
+
133
+ # Create a Device instance for interacting with a specific device
134
+ # @param ip [String] IP address of the MyChron device
135
+ # @return [Device] Device instance
136
+ #
137
+ # @example
138
+ # device = MyChron.device("192.168.1.29")
139
+ # sessions = device.sessions
140
+ # data = device.download("a_0077.xrz")
141
+ def device(ip)
142
+ Device.new(ip)
143
+ end
144
+
145
+ # ----- Convenience Methods -----
146
+
147
+ # List sessions on a device (convenience method)
148
+ # @param ip [String] IP address of the device
149
+ # @return [Array<Session>] Array of sessions
150
+ def sessions(ip)
151
+ device(ip).sessions
152
+ end
153
+
154
+ # Download a session file (convenience method)
155
+ # @param ip [String] IP address of the device
156
+ # @param filename [String] Session filename
157
+ # @yield [bytes_received, total_bytes] Progress callback
158
+ # @return [String] Binary file data
159
+ def download(ip, filename, &progress_block)
160
+ device(ip).download(filename, &progress_block)
161
+ end
162
+
163
+ # Download a session to a directory (convenience method)
164
+ # @param ip [String] IP address of the device
165
+ # @param filename [String] Session filename
166
+ # @param destination_dir [String] Directory to save the file
167
+ # @yield [bytes_received, total_bytes] Progress callback
168
+ # @return [String] Full path to saved file
169
+ def download_to(ip, filename, destination_dir, &progress_block)
170
+ device(ip).download_to(filename, destination_dir, &progress_block)
171
+ end
172
+
173
+ # ----- Advanced Discovery -----
174
+
175
+ # Perform network-based discovery using ARP + port scanning
176
+ # Use this if UDP discovery doesn't work (firewall, etc.)
177
+ # @param subnet [String, nil] Subnet to scan (auto-detected if nil)
178
+ # @param scan_ports [Boolean] Whether to scan ports
179
+ # @param probe_http [Boolean] Whether to probe HTTP endpoints
180
+ # @return [Array<Hash>] Array of candidate device hashes
181
+ def network_discover(subnet: nil, scan_ports: true, probe_http: true)
182
+ detector = Discovery::Detector.new(subnet: subnet)
183
+ # Convert boolean to nil (use default ports) or pass array through
184
+ ports = case scan_ports
185
+ when true then nil
186
+ when false then []
187
+ when Array then scan_ports
188
+ else nil
189
+ end
190
+ detector.run(scan_ports: ports, probe_http: probe_http)
191
+ end
192
+ end
193
+ end
data/mychron.gemspec ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/mychron/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mychron"
7
+ spec.version = MyChron::VERSION
8
+ spec.authors = ["Eugeniu"]
9
+ spec.email = ["eugeniu.rtj@gmail.com"]
10
+
11
+ spec.summary = "Ruby library for AiM MyChron 6 telemetry devices"
12
+ spec.description = <<~DESC
13
+ A Ruby gem for discovering and downloading telemetry data from AiM MyChron 6
14
+ kart data loggers over Wi-Fi. Works with Ruby on Rails and any Ruby application.
15
+
16
+ Features:
17
+ - UDP device discovery
18
+ - Session listing with lap times and metadata
19
+ - File download with progress callbacks
20
+ - Rails.logger integration
21
+ DESC
22
+ spec.homepage = "https://gitlab.com/eugeniu-rtj/mychron"
23
+ spec.license = "MIT"
24
+ spec.required_ruby_version = ">= 3.0.0"
25
+
26
+ spec.metadata["source_code_uri"] = spec.homepage
27
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
28
+
29
+ spec.files = Dir.glob(%w[
30
+ lib/**/*.rb
31
+ Gemfile
32
+ mychron.gemspec
33
+ README.md
34
+ LICENSE
35
+ PROTOCOL.md
36
+ IMPLEMENTATION_NOTES.md
37
+ ]).reject { |f| f.match?(%r{spec/|test/|\.git|exe/|cli\.rb}) }
38
+
39
+ spec.require_paths = ["lib"]
40
+
41
+ # No runtime dependencies - pure Ruby implementation
42
+ # The gem uses only standard library: socket, json, timeout, etc.
43
+
44
+ spec.add_development_dependency "rspec", "~> 3.13"
45
+ spec.add_development_dependency "rubocop", "~> 1.60"
46
+ spec.add_development_dependency "webmock", "~> 3.23"
47
+ spec.add_development_dependency "yard", "~> 0.9"
48
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mychron
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.2
5
+ platform: ruby
6
+ authors:
7
+ - Eugeniu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.13'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubocop
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.60'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.60'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webmock
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.23'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.23'
54
+ - !ruby/object:Gem::Dependency
55
+ name: yard
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
68
+ description: |
69
+ A Ruby gem for discovering and downloading telemetry data from AiM MyChron 6
70
+ kart data loggers over Wi-Fi. Works with Ruby on Rails and any Ruby application.
71
+
72
+ Features:
73
+ - UDP device discovery
74
+ - Session listing with lap times and metadata
75
+ - File download with progress callbacks
76
+ - Rails.logger integration
77
+ email:
78
+ - eugeniu.rtj@gmail.com
79
+ executables: []
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - Gemfile
84
+ - IMPLEMENTATION_NOTES.md
85
+ - LICENSE
86
+ - PROTOCOL.md
87
+ - README.md
88
+ - lib/mychron.rb
89
+ - lib/mychron/configuration.rb
90
+ - lib/mychron/device.rb
91
+ - lib/mychron/discovery/detector.rb
92
+ - lib/mychron/discovery/scorer.rb
93
+ - lib/mychron/errors.rb
94
+ - lib/mychron/logging.rb
95
+ - lib/mychron/monitor/watcher.rb
96
+ - lib/mychron/network/arp.rb
97
+ - lib/mychron/network/http_probe.rb
98
+ - lib/mychron/network/scanner.rb
99
+ - lib/mychron/protocol/client.rb
100
+ - lib/mychron/protocol/discovery.rb
101
+ - lib/mychron/session.rb
102
+ - lib/mychron/version.rb
103
+ - mychron.gemspec
104
+ homepage: https://gitlab.com/eugeniu-rtj/mychron
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ source_code_uri: https://gitlab.com/eugeniu-rtj/mychron
109
+ changelog_uri: https://gitlab.com/eugeniu-rtj/mychron/blob/main/CHANGELOG.md
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 3.0.0
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.6.9
125
+ specification_version: 4
126
+ summary: Ruby library for AiM MyChron 6 telemetry devices
127
+ test_files: []