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.
- checksums.yaml +7 -0
- data/Gemfile +5 -0
- data/IMPLEMENTATION_NOTES.md +539 -0
- data/LICENSE +21 -0
- data/PROTOCOL.md +848 -0
- data/README.md +413 -0
- data/lib/mychron/configuration.rb +112 -0
- data/lib/mychron/device.rb +196 -0
- data/lib/mychron/discovery/detector.rb +242 -0
- data/lib/mychron/discovery/scorer.rb +123 -0
- data/lib/mychron/errors.rb +27 -0
- data/lib/mychron/logging.rb +79 -0
- data/lib/mychron/monitor/watcher.rb +165 -0
- data/lib/mychron/network/arp.rb +257 -0
- data/lib/mychron/network/http_probe.rb +121 -0
- data/lib/mychron/network/scanner.rb +167 -0
- data/lib/mychron/protocol/client.rb +946 -0
- data/lib/mychron/protocol/discovery.rb +163 -0
- data/lib/mychron/session.rb +118 -0
- data/lib/mychron/version.rb +5 -0
- data/lib/mychron.rb +193 -0
- data/mychron.gemspec +48 -0
- metadata +127 -0
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
|