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
|
@@ -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
|