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,946 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module MyChron
6
+ module Protocol
7
+ # TCP Protocol client for communicating with MyChron devices
8
+ # Uses custom binary protocol with <hSTCP>/<STCP> framing on port 2000
9
+ class Client
10
+ include Logging
11
+
12
+ DATA_PORT = 2000
13
+ CONNECT_TIMEOUT = 5.0
14
+ READ_TIMEOUT = 30.0
15
+
16
+ # Protocol markers
17
+ HEADER_MARKER = "<hSTCP"
18
+ TAIL_MARKER = "<STCP"
19
+ HEADER_NC = "<hSTNC"
20
+ TAIL_NC = "<STNC"
21
+
22
+ # Command types (byte after header)
23
+ CMD_PING = 0x08
24
+ CMD_LIST = 0x44
25
+ CMD_DATA = 0x40
26
+
27
+ # Session info parsed from CSV
28
+ SessionInfo = Struct.new(
29
+ :filename,
30
+ :size,
31
+ :date,
32
+ :time,
33
+ :laps,
34
+ :best_lap,
35
+ :best_time,
36
+ :driver,
37
+ :speed_type,
38
+ :status,
39
+ :stop_mode,
40
+ :timestamp,
41
+ :device_name,
42
+ :longitude,
43
+ :latitude,
44
+ :duration,
45
+ keyword_init: true
46
+ )
47
+
48
+ def initialize(host, port: DATA_PORT)
49
+ @host = host
50
+ @port = port
51
+ @socket = nil
52
+ end
53
+
54
+ # Connect to device
55
+ def connect
56
+ log_debug( "Connecting to #{@host}:#{@port}")
57
+ @socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
58
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
59
+
60
+ addr = Socket.sockaddr_in(@port, @host)
61
+ begin
62
+ @socket.connect_nonblock(addr)
63
+ rescue IO::WaitWritable
64
+ IO.select(nil, [@socket], nil, CONNECT_TIMEOUT)
65
+ begin
66
+ @socket.connect_nonblock(addr)
67
+ rescue Errno::EISCONN
68
+ # Connected
69
+ rescue Errno::EALREADY, Errno::EINPROGRESS
70
+ raise ConnectionError, "Connection timeout to #{@host}:#{@port}"
71
+ end
72
+ rescue Errno::EHOSTUNREACH
73
+ raise ConnectionError, "No route to host #{@host} - device may be in sleep mode, try waking it"
74
+ rescue Errno::ECONNREFUSED
75
+ raise ConnectionError, "Connection refused by #{@host}:#{@port}"
76
+ rescue Errno::ENETUNREACH
77
+ raise ConnectionError, "Network unreachable for #{@host}"
78
+ end
79
+
80
+ log_info( "Connected to #{@host}:#{@port}")
81
+ true
82
+ end
83
+
84
+ # Disconnect from device
85
+ def disconnect
86
+ return unless @socket
87
+
88
+ @socket.close rescue nil
89
+ @socket = nil
90
+ log_debug( "Disconnected from #{@host}")
91
+ end
92
+
93
+ # List sessions on device
94
+ # Returns array of SessionInfo
95
+ #
96
+ # Protocol sequence (from RS3 capture analysis):
97
+ # 1. Handshake (type 0x08)
98
+ # 2. Init request (type 0x10 in STNC frame)
99
+ # 3. Date query (type 0x44/'D' with type 0x07) - initializes session list
100
+ # 4. ACK (type 0x04)
101
+ # 5. Type 0x51 Query (with 0xFFFFFFFF for all dates) - requests CSV
102
+ # 6. Type 0x24 Query - finalizes and triggers CSV output
103
+ # 7. Receive CSV response
104
+ def list_sessions
105
+ ensure_connected
106
+
107
+ # Send initial handshake
108
+ send_handshake
109
+ receive_handshake_response
110
+
111
+ # Send initialization (type 0x10) - required before query
112
+ send_init_request
113
+ receive_init_response
114
+
115
+ # Send date range query (type 0x44/D) - this initializes the session list
116
+ send_date_query
117
+ receive_date_response
118
+
119
+ # Send ACK
120
+ send_ack
121
+ sleep(0.1)
122
+
123
+ # Send type 0x51 Query (requests session list CSV)
124
+ send_list_request
125
+ receive_query_response
126
+
127
+ # Send type 0x24 Query (triggers CSV output)
128
+ send_finalize_query
129
+ sleep(0.1)
130
+
131
+ # Send ACK to signal ready for CSV data
132
+ send_ack
133
+
134
+ # Receive CSV response
135
+ csv_data = receive_list_response
136
+
137
+ parse_session_csv(csv_data)
138
+ end
139
+
140
+ # Download a session file
141
+ # Returns raw file data (bytes)
142
+ #
143
+ # IMPORTANT: Download requires a fresh connection.
144
+ # The session listing protocol leaves the connection in a different state,
145
+ # so we always reconnect before downloading.
146
+ def download_session(filename, &progress_block)
147
+ # Always start with a fresh connection for downloads
148
+ # This is critical because session listing leaves the device in a different state
149
+ disconnect if @socket
150
+ connect
151
+
152
+ # Send handshake first
153
+ send_handshake
154
+ receive_handshake_response
155
+
156
+ # Send download request
157
+ file_path = "1:/mem/#{filename}"
158
+ send_download_request(file_path)
159
+
160
+ # Receive file info and get size
161
+ file_size = receive_file_info(file_path)
162
+ log_info( "Downloading #{filename} (#{file_size} bytes)")
163
+
164
+ # Acknowledge info
165
+ send_ack
166
+
167
+ # Receive file data
168
+ receive_file_data(file_size, &progress_block)
169
+ end
170
+
171
+ private
172
+
173
+ def ensure_connected
174
+ connect unless @socket
175
+ end
176
+
177
+ def send_handshake
178
+ # Initial handshake packet:
179
+ # <hSTCP 08 00 0000 003e 00000000 0608 0000 <STCP 0e 00 3e
180
+ packet = build_packet(CMD_PING, [0x00, 0x00, 0x00, 0x3E], [0x00, 0x00, 0x00, 0x00, 0x06, 0x08, 0x00, 0x00])
181
+ log_debug( "Sending handshake: #{packet.bytes.map { |b| format('%02X', b) }.join(' ')}")
182
+ @socket.write(packet)
183
+ end
184
+
185
+ def receive_handshake_response
186
+ # Expect: <hSTCP 08 00 0000 003e
187
+ response = read_bytes(12)
188
+ log_debug("Handshake response: #{response.bytes.map { |b| format('%02X', b) }.join(' ')}")
189
+
190
+ unless response.start_with?(HEADER_MARKER)
191
+ raise ProtocolError, "Invalid handshake response: expected #{HEADER_MARKER}"
192
+ end
193
+
194
+ # May receive follow-up data
195
+ sleep(0.1)
196
+ if IO.select([@socket], nil, nil, 0.5)
197
+ extra = read_available
198
+ log_debug("Extra handshake data: #{extra.bytes.map { |b| format('%02X', b) }.join(' ')}")
199
+ end
200
+
201
+ true
202
+ end
203
+
204
+ def send_init_request
205
+ # Send initialization packet (type 0x10)
206
+ # From RS3 capture:
207
+ # <hSTNC 40 00 0000 003e [64-byte payload] <STNC 52 00 3e
208
+ #
209
+ # Payload:
210
+ # Bytes 8-9: 0x10 0x00 (type = init)
211
+ # Bytes 10-11: 0x01 0x00 (flags)
212
+ # Bytes 16-19: 0x40 0x00 0x00 0x00
213
+ # Bytes 24-27: 0x01 0x00 0x00 0x00 (count)
214
+
215
+ payload = Array.new(64, 0)
216
+
217
+ # Type 0x10 (init) at offset 8
218
+ payload[8] = 0x10
219
+ payload[9] = 0x00
220
+
221
+ # Flags at offset 10
222
+ payload[10] = 0x01
223
+ payload[11] = 0x00
224
+
225
+ # Value at offset 16
226
+ payload[16] = 0x40
227
+ payload[17] = 0x00
228
+ payload[18] = 0x00
229
+ payload[19] = 0x00
230
+
231
+ # Count at offset 24
232
+ payload[24] = 0x01
233
+ payload[25] = 0x00
234
+ payload[26] = 0x00
235
+ payload[27] = 0x00
236
+
237
+ packet = build_nc_packet(0x40, [0x00, 0x00, 0x00, 0x3E], payload)
238
+ log_debug("Sending init request (type 0x10): #{packet.bytesize} bytes")
239
+ @socket.write(packet)
240
+ end
241
+
242
+ def receive_init_response
243
+ # Wait for and consume the init response
244
+ # Server responds with <hSTCP>@ and possibly more data
245
+ data = read_available(timeout: 2.0)
246
+ log_debug("Init response: #{data.bytesize} bytes")
247
+
248
+ # May receive multiple response packets, consume them all
249
+ while IO.select([@socket], nil, nil, 0.5)
250
+ extra = read_available(timeout: 0.5)
251
+ break if extra.empty?
252
+ data += extra
253
+ log_debug("Additional init data: #{extra.bytesize} bytes (total: #{data.bytesize})")
254
+ end
255
+
256
+ true
257
+ end
258
+
259
+ def send_date_query
260
+ # Send date range query (type 0x44/'D' with type 0x07)
261
+ # From RS3 capture - this is a <hSTCP>D packet (88 bytes total)
262
+ #
263
+ # CRITICAL: RS3 uses specific current date values, not wide ranges!
264
+ # Using wide date ranges (2020-2030) returns 0 sessions.
265
+ # RS3 capture shows: year=2026, month=2, day=1, params=21/22
266
+ #
267
+ # Packet structure:
268
+ # <hSTCP>D (8 bytes header)
269
+ # Header suffix: 0x00 0x00 0x00 0x3E (4 bytes)
270
+ # Payload (68 bytes):
271
+ # 0-11: zeros
272
+ # 12-15: year (uint32 LE) - current year
273
+ # 16-19: month (uint32 LE) - current month
274
+ # 20-23: day (uint32 LE) - first day of month
275
+ # 24-27: param1 (uint32 LE) - calculated value
276
+ # 28-31: type (uint32 LE) - 0x07 for session list
277
+ # 32-43: zeros
278
+ # 44-47: year (uint32 LE) - same year
279
+ # 48-51: month (uint32 LE) - same month
280
+ # 52-55: day (uint32 LE) - same day
281
+ # 56-59: param2 (uint32 LE) - param1 + 1
282
+ # 60-63: type (uint32 LE) - 0x07
283
+ # 64-67: zeros
284
+ # Tail: <STCP>! 0x02 0x3E (8 bytes)
285
+
286
+ # Use current date values (matching RS3 behavior)
287
+ now = Time.now
288
+ year = now.year
289
+ month = now.month
290
+ day = 1 # RS3 uses first day of month
291
+
292
+ # Params from RS3 capture: 21 and 22 (0x15 and 0x16)
293
+ # These appear to be constant or calculated values
294
+ param1 = 0x15 # 21
295
+ param2 = 0x16 # 22
296
+
297
+ payload = Array.new(68, 0)
298
+
299
+ # Start date block
300
+ payload[12..15] = [year & 0xFF, (year >> 8) & 0xFF, 0x00, 0x00]
301
+ payload[16..19] = [month & 0xFF, 0x00, 0x00, 0x00]
302
+ payload[20..23] = [day & 0xFF, 0x00, 0x00, 0x00]
303
+ payload[24..27] = [param1 & 0xFF, 0x00, 0x00, 0x00]
304
+ payload[28..31] = [0x07, 0x00, 0x00, 0x00] # Type 0x07
305
+
306
+ # End date block (same date)
307
+ payload[44..47] = [year & 0xFF, (year >> 8) & 0xFF, 0x00, 0x00]
308
+ payload[48..51] = [month & 0xFF, 0x00, 0x00, 0x00]
309
+ payload[52..55] = [day & 0xFF, 0x00, 0x00, 0x00]
310
+ payload[56..59] = [param2 & 0xFF, 0x00, 0x00, 0x00]
311
+ payload[60..63] = [0x07, 0x00, 0x00, 0x00] # Type 0x07
312
+
313
+ # Build packet with STCP header and auto-calculated checksum
314
+ packet = build_packet(CMD_LIST, [0x00, 0x00, 0x00, 0x3E], payload)
315
+ log_debug("Sending date query (type 0x44/D): #{packet.bytesize} bytes, date: #{year}-#{month}-#{day}")
316
+ log_debug("Date query packet: #{packet.bytes.map { |b| format('%02X', b) }.join(' ')}")
317
+ @socket.write(packet)
318
+ end
319
+
320
+ def receive_date_response
321
+ # Wait for date query response
322
+ # Server should respond with session info
323
+ data = read_available(timeout: 3.0)
324
+ log_debug("Date response: #{data.bytesize} bytes")
325
+
326
+ # Consume any additional response data
327
+ while IO.select([@socket], nil, nil, 1.0)
328
+ extra = read_available(timeout: 1.0)
329
+ break if extra.empty?
330
+ data += extra
331
+ log_debug("Additional date data: #{extra.bytesize} bytes (total: #{data.bytesize})")
332
+ end
333
+
334
+ log_debug("Total date response: #{data.bytesize} bytes")
335
+ true
336
+ end
337
+
338
+ def send_list_request
339
+ # Send session list query (type 0x51 = 'Q' for Query)
340
+ # Discovered from RS3 capture analysis:
341
+ # - The 0x51 query type triggers session list CSV response
342
+ # - 0xFFFFFFFF at offset 32 means "all dates"
343
+ #
344
+ # Payload structure (64 bytes):
345
+ # Bytes 0-7: zeros
346
+ # Bytes 8-9: 0x51 0x00 (type = Query)
347
+ # Bytes 10-11: 0x02 0x00 (flags)
348
+ # Bytes 12-23: zeros
349
+ # Bytes 24-27: 0x01 0x00 0x00 0x00 (count = 1)
350
+ # Bytes 28-31: zeros
351
+ # Bytes 32-35: 0xFF 0xFF 0xFF 0xFF (all dates)
352
+ # Bytes 36-63: zeros
353
+ #
354
+ # Tail bytes: 0x50 0x04 0x3E (P, checksum, >)
355
+
356
+ payload = Array.new(64, 0)
357
+
358
+ # Type 0x51 (Query) at offset 8
359
+ payload[8] = 0x51
360
+ payload[9] = 0x00
361
+
362
+ # Flags 0x02 at offset 10
363
+ payload[10] = 0x02
364
+ payload[11] = 0x00
365
+
366
+ # Count = 1 at offset 24
367
+ payload[24] = 0x01
368
+ payload[25] = 0x00
369
+ payload[26] = 0x00
370
+ payload[27] = 0x00
371
+
372
+ # All dates (0xFFFFFFFF) at offset 32
373
+ payload[32] = 0xFF
374
+ payload[33] = 0xFF
375
+ payload[34] = 0xFF
376
+ payload[35] = 0xFF
377
+
378
+ packet = build_nc_packet(0x40, [0x00, 0x00, 0x00, 0x3E], payload)
379
+ log_debug("Sending list request (type 0x51 Query): #{packet.bytesize} bytes")
380
+ log_debug("List packet: #{packet.bytes.map { |b| format('%02X', b) }.join(' ')}")
381
+ @socket.write(packet)
382
+ end
383
+
384
+ def receive_query_response
385
+ # Receive and consume the response to type 0x51 query
386
+ # Device responds with ACK and some data
387
+ data = read_available(timeout: 3.0)
388
+ log_debug("Query response: #{data.bytesize} bytes")
389
+
390
+ # Consume any additional response data
391
+ while IO.select([@socket], nil, nil, 1.0)
392
+ extra = read_available(timeout: 1.0)
393
+ break if extra.empty?
394
+ data += extra
395
+ log_debug("Additional query data: #{extra.bytesize} bytes (total: #{data.bytesize})")
396
+ end
397
+
398
+ true
399
+ end
400
+
401
+ def send_finalize_query
402
+ # Send type 0x24 query that triggers CSV output
403
+ # From RS3 capture: <hSTNC 40 00 0000 003e [payload] <STNC ' 00 3e
404
+ #
405
+ # Payload structure (64 bytes):
406
+ # Bytes 0-7: zeros
407
+ # Bytes 8-9: 0x24 0x00 (type = 0x24)
408
+ # Bytes 10-11: 0x02 0x00 (flags)
409
+ # Bytes 12-23: zeros
410
+ # Bytes 24-27: 0x01 0x00 0x00 0x00 (count)
411
+ # Bytes 28-63: zeros
412
+
413
+ payload = Array.new(64, 0)
414
+
415
+ # Type 0x24 at offset 8
416
+ payload[8] = 0x24
417
+ payload[9] = 0x00
418
+
419
+ # Flags 0x02 at offset 10
420
+ payload[10] = 0x02
421
+ payload[11] = 0x00
422
+
423
+ # Count = 1 at offset 24
424
+ payload[24] = 0x01
425
+ payload[25] = 0x00
426
+ payload[26] = 0x00
427
+ payload[27] = 0x00
428
+
429
+ # Tail bytes: 0x27 0x00 0x3E (apostrophe, 0, >)
430
+ packet = build_nc_packet(0x40, [0x00, 0x00, 0x00, 0x3E], payload)
431
+ log_debug("Sending finalize query (type 0x24): #{packet.bytesize} bytes")
432
+ log_debug("Finalize packet: #{packet.bytes.map { |b| format('%02X', b) }.join(' ')}")
433
+ @socket.write(packet)
434
+ end
435
+
436
+ def receive_list_response
437
+ # Read all response data until we see the end marker or CSV data ends
438
+ # Server responds to type 0x51 query with:
439
+ # 1. ACK (<hSTCP>@ type 0x40)
440
+ # 2. Some config data
441
+ # 3. More exchanges
442
+ # 4. Eventually <hSTCP>J (type 0x4A) followed by CSV data
443
+ data = ""
444
+ deadline = Time.now + READ_TIMEOUT
445
+ received_csv = false
446
+
447
+ while Time.now < deadline
448
+ chunk = read_available(timeout: 2.0)
449
+
450
+ if chunk.empty?
451
+ # Check if we have enough CSV data
452
+ break if received_csv && data.include?("\n")
453
+ next
454
+ end
455
+
456
+ data += chunk
457
+
458
+ # Check if we've received CSV header
459
+ if data.include?("name,size,date,hour,") || data.include?("name,size,date,time,")
460
+ received_csv = true
461
+ end
462
+
463
+ # Check for end marker after CSV data
464
+ if received_csv && (data.include?("<STCP;") || data.rindex("<STCP") && data.rindex("<STCP") > data.rindex("xrz"))
465
+ log_debug("Received complete session list: #{data.bytesize} bytes")
466
+ break
467
+ end
468
+
469
+ # Safety: if we've received a lot of data, check if CSV is complete
470
+ if received_csv && data.bytesize > 10000
471
+ # Check if last session line seems complete
472
+ last_newline = data.rindex("\n")
473
+ if last_newline && (data.bytesize - last_newline) > 200
474
+ log_debug("Received #{data.bytesize} bytes, CSV appears complete")
475
+ break
476
+ end
477
+ end
478
+ end
479
+
480
+ log_debug("Total response: #{data.bytesize} bytes")
481
+
482
+ # Extract CSV content between headers
483
+ extract_csv_content(data)
484
+ end
485
+
486
+ def extract_csv_content(data)
487
+ # Find CSV data - it's between the binary headers
488
+ # Look for the CSV header line (RS3 uses "hour" instead of "time")
489
+ csv_start = data.index("name,size,date,hour,")
490
+ csv_start ||= data.index("name,size,date,time,")
491
+ return "" unless csv_start
492
+
493
+ # Find end of CSV data - look for protocol marker after the CSV
494
+ csv_end = data.length
495
+
496
+ # Find the last session file reference (ends with .xrz, .hrz, etc.)
497
+ last_session_match = data.rindex(/\.(xrz|hrz|xrk|drk),/)
498
+ if last_session_match
499
+ # Find the next newline after the last session
500
+ next_newline = data.index("\n", last_session_match)
501
+ if next_newline
502
+ csv_end = next_newline + 1
503
+ end
504
+ end
505
+
506
+ # Also check for protocol markers
507
+ marker_idx = data.index("<STCP", csv_start)
508
+ marker_idx ||= data.index("<hSTCP", csv_start)
509
+ csv_end = [csv_end, marker_idx].compact.min if marker_idx
510
+
511
+ csv_data = data[csv_start...csv_end]
512
+ csv_data.force_encoding("UTF-8")
513
+ end
514
+
515
+ def parse_session_csv(csv_data)
516
+ return [] if csv_data.nil? || csv_data.empty?
517
+
518
+ lines = csv_data.split(/\r?\n/)
519
+ return [] if lines.empty?
520
+
521
+ # First line is header
522
+ header = lines.shift
523
+ fields = header.split(",").map(&:strip)
524
+
525
+ sessions = []
526
+ lines.each do |line|
527
+ next if line.strip.empty?
528
+ next if line.start_with?("<") # Skip protocol markers
529
+
530
+ values = line.split(",")
531
+ next if values.length < 5 # Skip incomplete lines
532
+
533
+ session = parse_session_line(fields, values)
534
+ sessions << session if session
535
+ end
536
+
537
+ sessions
538
+ end
539
+
540
+ def parse_session_line(fields, values)
541
+ # CSV format from RS3 capture:
542
+ # name,size,date,hour,nlap,nbest,best,pilota,track_name,veicolo,campionato,venue_type,mode,trk_type,motivolap,maxvel,device,track_lat,track_lon,test_dur,...
543
+ #
544
+ # Field indices:
545
+ # 0: name (filename)
546
+ # 1: size
547
+ # 2: date
548
+ # 3: hour (time)
549
+ # 4: nlap (laps)
550
+ # 5: nbest (best lap number)
551
+ # 6: best (best time in ms)
552
+ # 7: pilota (driver)
553
+ # 8: track_name
554
+ # 9: veicolo (vehicle)
555
+ # 10: campionato (championship)
556
+ # 11: venue_type
557
+ # 12: mode (speed_type)
558
+ # 13: trk_type (status)
559
+ # 14: motivolap (stop_mode)
560
+ # 15: maxvel (max velocity)
561
+ # 16: device
562
+ # 17: track_lat
563
+ # 18: track_lon
564
+ # 19: test_dur (duration)
565
+
566
+ filename = values[0]
567
+ return nil unless filename&.match?(/\.(xrz|xrk|hrz|drk)$/i)
568
+
569
+ SessionInfo.new(
570
+ filename: filename,
571
+ size: values[1]&.to_i,
572
+ date: values[2],
573
+ time: values[3],
574
+ laps: values[4]&.to_i,
575
+ best_lap: values[5]&.to_i,
576
+ best_time: values[6]&.to_i,
577
+ driver: values[7],
578
+ speed_type: values[12],
579
+ status: values[13],
580
+ stop_mode: values[14],
581
+ timestamp: values[15]&.to_i,
582
+ device_name: values[16],
583
+ longitude: values[17]&.to_i,
584
+ latitude: values[18]&.to_i,
585
+ duration: values[19]&.to_i
586
+ )
587
+ rescue StandardError => e
588
+ log_warn("Failed to parse session line: #{e.message}")
589
+ nil
590
+ end
591
+
592
+ # Build a STCP packet with auto-calculated 16-bit payload checksum tail
593
+ def build_packet(cmd, header_suffix, payload)
594
+ checksum = payload.sum
595
+ packet = HEADER_MARKER.dup
596
+ packet << cmd.chr
597
+ packet << 0x00.chr
598
+ header_suffix.each { |b| packet << b.chr }
599
+ payload.each { |b| packet << b.chr }
600
+ packet << TAIL_MARKER
601
+ packet << (checksum & 0xFF).chr
602
+ packet << ((checksum >> 8) & 0xFF).chr
603
+ packet << 0x3E.chr
604
+ packet
605
+ end
606
+
607
+ # Build a STNC packet with auto-calculated 16-bit payload checksum tail
608
+ def build_nc_packet(cmd, header_suffix, payload)
609
+ checksum = payload.sum
610
+ packet = HEADER_NC.dup
611
+ packet << cmd.chr
612
+ packet << 0x00.chr
613
+ header_suffix.each { |b| packet << b.chr }
614
+ payload.each { |b| packet << b.chr }
615
+ packet << TAIL_NC
616
+ packet << (checksum & 0xFF).chr
617
+ packet << ((checksum >> 8) & 0xFF).chr
618
+ packet << 0x3E.chr
619
+ packet
620
+ end
621
+
622
+ def read_bytes(n)
623
+ data = ""
624
+ deadline = Time.now + READ_TIMEOUT
625
+
626
+ while data.bytesize < n && Time.now < deadline
627
+ remaining = n - data.bytesize
628
+ chunk = @socket.recv(remaining)
629
+ raise ConnectionError, "Connection closed" if chunk.empty?
630
+
631
+ data += chunk
632
+ end
633
+
634
+ data
635
+ end
636
+
637
+ def read_available(timeout: 0.5)
638
+ data = "".b
639
+ deadline = Time.now + timeout
640
+ got_data = false
641
+
642
+ while Time.now < deadline
643
+ readable = IO.select([@socket], nil, nil, 0.1)
644
+
645
+ unless readable
646
+ # No data available yet - if we already got some data, we can stop
647
+ # Otherwise keep waiting until timeout
648
+ break if got_data
649
+ next
650
+ end
651
+
652
+ begin
653
+ chunk = @socket.recv_nonblock(4096)
654
+ break if chunk.nil? || chunk.empty?
655
+
656
+ data += chunk
657
+ got_data = true
658
+ rescue IO::WaitReadable
659
+ # No more data available right now
660
+ next
661
+ rescue Errno::ECONNRESET
662
+ raise ConnectionError, "Connection reset by peer"
663
+ end
664
+ end
665
+
666
+ data
667
+ end
668
+
669
+ # Download protocol methods
670
+
671
+ def send_download_request(file_path)
672
+ # Build download request packet
673
+ # <hSTNC 40 00 0000 003e [64-byte payload] <STNC 2f 05 3e
674
+ #
675
+ # Payload structure (64 bytes, from capture analysis):
676
+ # Offset 0-7: zeros
677
+ # Offset 8-11: flags (0x02 0x00 0x04 0x00)
678
+ # Offset 12-23: zeros
679
+ # Offset 24-27: count (0x01 0x00 0x00 0x00)
680
+ # Offset 28-31: zeros
681
+ # Offset 32-55: file path (24 bytes, null-terminated)
682
+ # Offset 56-63: zeros
683
+
684
+ payload = Array.new(64, 0)
685
+
686
+ # Flags at offset 8 - verified from capture: 02 00 04 00
687
+ # (0x04 does NOT delete - files are just hidden from RS3 after download)
688
+ payload[8] = 0x02
689
+ payload[9] = 0x00
690
+ payload[10] = 0x04
691
+ payload[11] = 0x00
692
+
693
+ # Count at offset 24
694
+ payload[24] = 0x01
695
+ payload[25] = 0x00
696
+ payload[26] = 0x00
697
+ payload[27] = 0x00
698
+
699
+ # File path at offset 32 (null-terminated, max 24 bytes)
700
+ path_bytes = file_path.bytes
701
+ path_bytes.each_with_index do |b, i|
702
+ break if i >= 23 # Leave room for null terminator
703
+ payload[32 + i] = b
704
+ end
705
+
706
+ # Tail bytes are auto-calculated 16-bit checksum of payload
707
+ # (e.g., a_0077.xrz -> 0x052F, a_0065.xrz -> 0x052C)
708
+ packet = build_nc_packet(0x40, [0x00, 0x00, 0x00, 0x3E], payload)
709
+ log_debug( "Sending download request for #{file_path} (#{packet.bytesize} bytes)")
710
+ log_debug( "Download packet: #{packet.bytes.map { |b| format('%02X', b) }.join(' ')}")
711
+ @socket.write(packet)
712
+ end
713
+
714
+ def receive_file_info(file_path)
715
+ # Read response until we get file size info
716
+ # Response contains TWO info blocks:
717
+ # 1. First block: has path but size field is zeros
718
+ # 2. Second block: has path AND the actual file size
719
+ #
720
+ # Each block has format:
721
+ # <hSTCP@...> header (12 bytes)
722
+ # 8 bytes zeros
723
+ # 02 00 04 00 (flags)
724
+ # 00 00 00 00 OR [size] (4 bytes - zeros in first, size in second)
725
+ # C0 FF 00 00 (marker)
726
+ # ...
727
+ # path string
728
+ # <STCP...> tail
729
+
730
+ data = read_available(timeout: 2.0)
731
+
732
+ log_debug("File info response (#{data.bytesize} bytes): #{data.bytes.take(100).map { |b| format('%02X', b) }.join(' ')}")
733
+
734
+ if data.bytesize < 100
735
+ log_debug("Response too short, looking for path: #{file_path}")
736
+ raise ProtocolError, "File info response too short (#{data.bytesize} bytes)"
737
+ end
738
+
739
+ data.force_encoding('ASCII-8BIT')
740
+
741
+ # Find file size in the SECOND info block
742
+ # Look for the second occurrence of flags 0x02 0x00 0x04 0x00
743
+ # Size is at offset +8 from the flags
744
+ flag_pattern = "\x02\x00\x04\x00".b
745
+ first_idx = data.index(flag_pattern)
746
+
747
+ if first_idx.nil?
748
+ log_debug("No flags found in response")
749
+ raise ProtocolError, "File info response missing flags pattern"
750
+ end
751
+
752
+ second_idx = data.index(flag_pattern, first_idx + 4)
753
+ if second_idx && second_idx + 12 <= data.bytesize
754
+ # Size is at offset +8 from the SECOND flags occurrence
755
+ size_bytes = data.byteslice(second_idx + 8, 4)
756
+ file_size = size_bytes.unpack1("V")
757
+
758
+ if file_size > 0 && file_size < 100_000_000
759
+ log_debug("File size: #{file_size} bytes (from second info block)")
760
+ return file_size
761
+ end
762
+ end
763
+
764
+ # Fallback: try the first block (in case there's only one)
765
+ if first_idx + 12 <= data.bytesize
766
+ size_bytes = data.byteslice(first_idx + 8, 4)
767
+ file_size = size_bytes.unpack1("V")
768
+
769
+ if file_size > 0 && file_size < 100_000_000
770
+ log_debug("File size: #{file_size} bytes (from first info block)")
771
+ return file_size
772
+ end
773
+ end
774
+
775
+ log_debug("Could not find valid file size in response")
776
+ raise ProtocolError, "Could not determine file size from response"
777
+ end
778
+
779
+ def send_ack
780
+ # Send simple acknowledgment: <hSTCP 04 00 0000 003e 00000000 <STCP 00 00 3e
781
+ packet = build_packet(0x04, [0x00, 0x00, 0x00, 0x3E], [0x00, 0x00, 0x00, 0x00])
782
+ @socket.write(packet)
783
+ end
784
+
785
+ def send_data_ack(bytes_received, chunk_num)
786
+ # Send data transfer acknowledgment with progress counter
787
+ # Payload: 2-byte counter (starts 0xFFC0, decrements 0x40/ACK) + 2-byte sequence
788
+ # Tail checksum is auto-calculated by build_packet
789
+ counter = [0xFFC0 - (chunk_num * 0x40), 0].max
790
+
791
+ payload = [
792
+ counter & 0xFF,
793
+ (counter >> 8) & 0xFF,
794
+ chunk_num & 0xFF,
795
+ 0x00
796
+ ]
797
+
798
+ packet = build_packet(0x04, [0x00, 0x00, 0x00, 0x3E], payload)
799
+ log_debug( "Sending data ACK ##{chunk_num}: #{packet.bytes.map { |b| format('%02X', b) }.join(' ')}")
800
+ @socket.write(packet)
801
+ end
802
+
803
+ def receive_file_data(file_size, &progress_block)
804
+ # Read file data, stripping protocol markers
805
+ # Data comes with <hSTCP c4 ff> header and periodic <STCP XX YY> markers
806
+ # We need to collect raw data until we have file_size bytes
807
+ #
808
+ # IMPORTANT: The device sends ~64KB chunks and then waits for an ACK
809
+ # We must send periodic ACKs to continue receiving data
810
+
811
+ file_data = "".b # Binary string
812
+ buffer = "".b
813
+ deadline = Time.now + 300 # 5 minute timeout for large files
814
+
815
+ # ACK tracking
816
+ chunk_size = 64 * 1024 # ~64KB per chunk
817
+ last_ack_at = 0
818
+ chunk_num = 0
819
+ no_data_count = 0
820
+
821
+ while file_data.bytesize < file_size && Time.now < deadline
822
+ # Read more data
823
+ chunk = read_available(timeout: 2.0)
824
+
825
+ if chunk.empty?
826
+ no_data_count += 1
827
+
828
+ # If we've received some data since last ACK but data stopped, send ACK quickly
829
+ # Device may have a short timeout, so respond within 1-2 seconds
830
+ bytes_since_ack = file_data.bytesize - last_ack_at
831
+ if bytes_since_ack > 0 && no_data_count >= 1
832
+ log_debug( "No data for ~2s, sending ACK ##{chunk_num} (received #{bytes_since_ack} since last ACK)")
833
+ send_data_ack(file_data.bytesize, chunk_num)
834
+ chunk_num += 1
835
+ last_ack_at = file_data.bytesize
836
+ no_data_count = 0
837
+ next
838
+ end
839
+
840
+ # Give up after extended no-data period (20 seconds)
841
+ break if no_data_count > 10 && buffer.empty?
842
+ next
843
+ end
844
+
845
+ no_data_count = 0
846
+ buffer += chunk
847
+
848
+ # Process buffer, extracting data and removing protocol markers
849
+ prev_size = file_data.bytesize
850
+ buffer = extract_file_data(buffer, file_data, file_size)
851
+
852
+ # Check if we need to send an ACK (every ~64KB)
853
+ bytes_since_ack = file_data.bytesize - last_ack_at
854
+ if bytes_since_ack >= chunk_size
855
+ log_debug( "Received #{bytes_since_ack} bytes since last ACK, sending ACK ##{chunk_num}")
856
+ send_data_ack(file_data.bytesize, chunk_num)
857
+ chunk_num += 1
858
+ last_ack_at = file_data.bytesize
859
+ end
860
+
861
+ # Report progress
862
+ if progress_block && file_data.bytesize > prev_size
863
+ progress_block.call(file_data.bytesize, file_size)
864
+ end
865
+ end
866
+
867
+ if file_data.bytesize < file_size
868
+ log_warn( "Incomplete download: got #{file_data.bytesize}/#{file_size} bytes")
869
+ else
870
+ log_info( "Download complete: #{file_data.bytesize} bytes")
871
+ end
872
+
873
+ file_data
874
+ end
875
+
876
+ def extract_file_data(buffer, file_data, target_size)
877
+ # Remove protocol markers from buffer and append data to file_data
878
+ #
879
+ # Data frame structure (from raw TCP stream capture):
880
+ # Header: <hSTCP (6) + cmd (1) + 0xFF (1) + suffix (4) + counter (4) = 16 bytes
881
+ # Payload: ~65KB of file data
882
+ # Tail: <STCP (5) + checksum_lo (1) + checksum_hi (1) + 0x3E (1) = 8 bytes
883
+ #
884
+ # The 4-byte counter field at offset 12-15 contains:
885
+ # - 00 00 00 00 for the initial data frame
886
+ # - ACK counter values (C0 FF 00 00, etc.) for subsequent frames
887
+ # This counter is NOT file data and must be stripped with the header.
888
+
889
+ remaining = buffer.dup
890
+
891
+ loop do
892
+ break if remaining.empty?
893
+ break if file_data.bytesize >= target_size
894
+
895
+ # Look for protocol markers
896
+ header_idx = remaining.index(HEADER_MARKER)
897
+ tail_idx = remaining.index(TAIL_MARKER)
898
+
899
+ if header_idx == 0
900
+ if remaining.bytesize < 16
901
+ break # Need more data to process full header (16 bytes)
902
+ end
903
+
904
+ # Strip 16-byte data frame header:
905
+ # <hSTCP (6) + cmd (1) + flag (1) + suffix (4) + counter (4)
906
+ remaining = remaining.byteslice(16..-1) || ""
907
+ next
908
+ elsif tail_idx == 0
909
+ # Skip tail (8 bytes: <STCP + 2 suffix + > or just <STCP XX XX)
910
+ # Tail format: <STCP followed by 2 bytes then > (or end)
911
+ skip_len = [8, remaining.bytesize].min
912
+ remaining = remaining.byteslice(skip_len..-1) || ""
913
+ next
914
+ end
915
+
916
+ # Find next marker
917
+ next_marker = [header_idx, tail_idx].compact.min
918
+
919
+ if next_marker
920
+ # Copy data up to marker
921
+ data_chunk = remaining.byteslice(0, next_marker)
922
+ needed = target_size - file_data.bytesize
923
+ to_copy = [data_chunk.bytesize, needed].min
924
+ file_data << data_chunk.byteslice(0, to_copy)
925
+ remaining = remaining.byteslice(next_marker..-1) || ""
926
+ else
927
+ # No marker found, copy all remaining (but keep some buffer for partial markers)
928
+ safe_len = [remaining.bytesize - 12, 0].max # Keep 12 bytes for partial marker
929
+ if safe_len > 0
930
+ needed = target_size - file_data.bytesize
931
+ to_copy = [safe_len, needed].min
932
+ file_data << remaining.byteslice(0, to_copy)
933
+ remaining = remaining.byteslice(to_copy..-1) || ""
934
+ end
935
+ break # Need more data
936
+ end
937
+ end
938
+
939
+ remaining
940
+ end
941
+ end
942
+
943
+ class ConnectionError < StandardError; end
944
+ class ProtocolError < StandardError; end
945
+ end
946
+ end