rbzk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/rbzk/zk.rb ADDED
@@ -0,0 +1,2416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'timeout'
5
+ require 'date'
6
+
7
+ module RBZK
8
+ # Helper class for ZK (like Python's ZK_helper)
9
+ class ZKHelper
10
+ def initialize(ip, port = 4370)
11
+ @ip = ip
12
+ @port = port
13
+ @address = [ ip, port ]
14
+ end
15
+
16
+ def test_ping
17
+ # Like Python's test_ping
18
+ begin
19
+ system("ping -c 1 -W 5 #{@ip} > /dev/null 2>&1")
20
+ return $?.success?
21
+ rescue => e
22
+ return false
23
+ end
24
+ end
25
+
26
+ def test_tcp
27
+ # Match Python's test_tcp method exactly
28
+ begin
29
+ # Create socket like Python's socket(AF_INET, SOCK_STREAM)
30
+ client = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
31
+
32
+ # Set timeout like Python's settimeout(10)
33
+ client.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [ 10, 0 ].pack('l_*'))
34
+
35
+ # Use connect_ex like Python (returns error code instead of raising exception)
36
+ sockaddr = Socket.pack_sockaddr_in(@port, @ip)
37
+ begin
38
+ client.connect(sockaddr)
39
+ result = 0 # Success, like Python's connect_ex returns 0 on success
40
+ rescue Errno::EISCONN
41
+ # Already connected
42
+ result = 0
43
+ rescue => e
44
+ # Connection failed, return error code
45
+ result = e.errno || 1
46
+ end
47
+
48
+ # Close socket
49
+ client.close
50
+
51
+ # Return result code (0 = success, non-zero = error)
52
+ return result
53
+ rescue => e
54
+ # Something went wrong with socket creation
55
+ return e.errno || 1
56
+ end
57
+ end
58
+ end
59
+
60
+ class ZK
61
+ include RBZK::Constants
62
+
63
+ def initialize(ip, port: 4370, timeout: 60, password: 0, force_udp: false, omit_ping: false, verbose: false, encoding: 'UTF-8')
64
+ # Match Python's __init__ method
65
+ RBZK::User.encoding = encoding
66
+ @address = [ ip, port ]
67
+
68
+ @ip = ip
69
+ @port = port
70
+ @timeout = timeout
71
+ @password = password
72
+ @force_udp = force_udp
73
+ @omit_ping = omit_ping
74
+ @verbose = verbose
75
+ @encoding = encoding
76
+
77
+ # Set TCP mode based on force_udp (like Python's self.tcp = not force_udp)
78
+ @tcp = !force_udp
79
+
80
+ # Socket will be created during connect
81
+ @socket = nil
82
+
83
+ # Initialize session variables (like Python)
84
+ @session_id = 0
85
+ @reply_id = USHRT_MAX - 1
86
+ @data_recv = nil
87
+ @data = nil
88
+ @connected = false
89
+ @next_uid = 1
90
+
91
+ # Storage for user and attendance data
92
+ @users = {}
93
+ @attendances = []
94
+ @fingers = {}
95
+ @tcp_header_size = 8
96
+
97
+ # Initialize device info variables (like Python)
98
+ @users = 0
99
+ @fingers = 0
100
+ @records = 0
101
+ @dummy = 0
102
+ @cards = 0
103
+ @fingers_cap = 0
104
+ @users_cap = 0
105
+ @rec_cap = 0
106
+ @faces = 0
107
+ @faces_cap = 0
108
+ @fingers_av = 0
109
+ @users_av = 0
110
+ @rec_av = 0
111
+
112
+ # Create helper for ping and TCP tests
113
+ @helper = ZKHelper.new(ip, port)
114
+
115
+ if @verbose
116
+ puts "ZK instance created for device at #{@ip}:#{@port}"
117
+ puts "Using #{@force_udp ? 'UDP' : 'TCP'} mode"
118
+ end
119
+ end
120
+
121
+ # Remove this method as we now use the helper class
122
+ # def test_tcp
123
+ # end
124
+
125
+ def connect
126
+ # Match Python's connect method
127
+ return self if @connected
128
+
129
+ # Skip ping check if requested (like Python's ommit_ping)
130
+ if !@omit_ping && !@helper.test_ping
131
+ raise RBZK::ZKNetworkError, "Can't reach device (ping #{@ip})"
132
+ end
133
+
134
+ # Test TCP connection (like Python's connect)
135
+ if !@force_udp && @helper.test_tcp == 0
136
+ # Default user packet size for ZK8
137
+ @user_packet_size = 72
138
+ end
139
+
140
+ # Create socket (like Python's __create_socket)
141
+ create_socket
142
+
143
+ # Reset session variables (like Python's connect)
144
+ @session_id = 0
145
+ @reply_id = USHRT_MAX - 1
146
+
147
+ # Send connect command (like Python's connect)
148
+ if @verbose
149
+ puts "Sending connect command to device"
150
+ end
151
+
152
+ begin
153
+ # Send connect command (like Python's connect)
154
+ # In Python: cmd_response = self.__send_command(const.CMD_CONNECT)
155
+ # No command string is needed for the connect command
156
+ cmd_response = send_command(CMD_CONNECT)
157
+
158
+ # Update session ID from header (like Python's connect)
159
+ @session_id = @header[2]
160
+
161
+ # Authenticate if needed (like Python's connect)
162
+ if cmd_response[:code] == CMD_ACK_UNAUTH
163
+ if @verbose
164
+ puts "try auth"
165
+ end
166
+
167
+ # Create auth command string (like Python's make_commkey)
168
+ command_string = make_commkey(@password, @session_id)
169
+
170
+ # Send auth command
171
+ cmd_response = send_command(CMD_AUTH, command_string)
172
+ end
173
+
174
+ # Check response status (like Python's connect)
175
+ if cmd_response[:status]
176
+ @connected = true
177
+ return self
178
+ else
179
+ if cmd_response[:code] == CMD_ACK_UNAUTH
180
+ raise RBZK::ZKErrorResponse, "Unauthenticated"
181
+ end
182
+
183
+ if @verbose
184
+ puts "Connect error response: #{cmd_response[:code]}"
185
+ end
186
+
187
+ raise RBZK::ZKErrorResponse, "Invalid response: Can't connect"
188
+ end
189
+ rescue => e
190
+ @connected = false
191
+ if @verbose
192
+ puts "Connection error: #{e.message}"
193
+ end
194
+ raise e
195
+ end
196
+ end
197
+
198
+ def connected?
199
+ @connected
200
+ end
201
+
202
+ def disconnect
203
+ return unless @connected
204
+
205
+ self.send_command(CMD_EXIT)
206
+ self.recv_reply
207
+
208
+ @connected = false
209
+ @socket.close if @socket
210
+
211
+ @socket = nil
212
+ @tcp = nil
213
+
214
+ true
215
+ end
216
+
217
+ def enable_device
218
+ self.send_command(CMD_ENABLEDEVICE)
219
+ self.recv_reply
220
+ true
221
+ end
222
+
223
+ def disable_device
224
+ # Match Python's disable_device method exactly
225
+ # In Python:
226
+ # def disable_device(self):
227
+ # cmd_response = self.__send_command(const.CMD_DISABLEDEVICE)
228
+ # if cmd_response.get('status'):
229
+ # self.is_enabled = False
230
+ # return True
231
+ # else:
232
+ # raise ZKErrorResponse("Can't disable device")
233
+
234
+ cmd_response = self.send_command(CMD_DISABLEDEVICE)
235
+ if cmd_response[:status]
236
+ @is_enabled = false
237
+ return true
238
+ else
239
+ raise RBZK::ZKErrorResponse, "Can't disable device"
240
+ end
241
+ end
242
+
243
+ def get_firmware_version
244
+ # Match Python's get_firmware_version method exactly
245
+ # In Python:
246
+ # def get_firmware_version(self):
247
+ # cmd_response = self.__send_command(const.CMD_GET_VERSION,b'', 1024)
248
+ # if cmd_response.get('status'):
249
+ # firmware_version = self.__data.split(b'\x00')[0]
250
+ # return firmware_version.decode()
251
+ # else:
252
+ # raise ZKErrorResponse("Can't read frimware version")
253
+
254
+ command = CMD_GET_VERSION
255
+ response_size = 1024
256
+ response = self.send_command(command, "", response_size)
257
+
258
+ if response && response[:status]
259
+ firmware_version = @data.split("\x00")[0]
260
+ return firmware_version.to_s
261
+ else
262
+ raise RBZK::ZKErrorResponse, "Can't read firmware version"
263
+ end
264
+ end
265
+
266
+ def get_serialnumber
267
+ # Match Python's get_serialnumber method exactly
268
+ # In Python:
269
+ # def get_serialnumber(self):
270
+ # command = const.CMD_OPTIONS_RRQ
271
+ # command_string = b'~SerialNumber\x00'
272
+ # response_size = 1024
273
+ # cmd_response = self.__send_command(command, command_string, response_size)
274
+ # if cmd_response.get('status'):
275
+ # serialnumber = self.__data.split(b'=', 1)[-1].split(b'\x00')[0]
276
+ # serialnumber = serialnumber.replace(b'=', b'')
277
+ # return serialnumber.decode() # string?
278
+ # else:
279
+ # raise ZKErrorResponse("Can't read serial number")
280
+
281
+ command = CMD_OPTIONS_RRQ
282
+ command_string = "~SerialNumber\x00".b
283
+ response_size = 1024
284
+
285
+ response = self.send_command(command, command_string, response_size)
286
+
287
+ if response && response[:status]
288
+ serialnumber = @data.split("=", 2)[1]&.split("\x00")[0] || ""
289
+ serialnumber = serialnumber.gsub("=", "")
290
+ return serialnumber.to_s
291
+ else
292
+ raise RBZK::ZKErrorResponse, "Can't read serial number"
293
+ end
294
+ end
295
+
296
+ def get_mac
297
+ # Match Python's get_mac method exactly
298
+ # In Python:
299
+ # def get_mac(self):
300
+ # command = const.CMD_OPTIONS_RRQ
301
+ # command_string = b'MAC\x00'
302
+ # response_size = 1024
303
+ # cmd_response = self.__send_command(command, command_string, response_size)
304
+ # if cmd_response.get('status'):
305
+ # mac = self.__data.split(b'=', 1)[-1].split(b'\x00')[0]
306
+ # return mac.decode()
307
+ # else:
308
+ # raise ZKErrorResponse("can't read mac address")
309
+
310
+ command = CMD_OPTIONS_RRQ
311
+ command_string = "MAC\x00".b
312
+ response_size = 1024
313
+
314
+ response = self.send_command(command, command_string, response_size)
315
+
316
+ if response && response[:status]
317
+ mac = @data.split("=", 2)[1]&.split("\x00")[0] || ""
318
+ return mac.to_s
319
+ else
320
+ raise RBZK::ZKErrorResponse, "Can't read MAC address"
321
+ end
322
+ end
323
+
324
+ def get_device_name
325
+ # Match Python's get_device_name method exactly
326
+ # In Python:
327
+ # def get_device_name(self):
328
+ # command = const.CMD_OPTIONS_RRQ
329
+ # command_string = b'~DeviceName\x00'
330
+ # response_size = 1024
331
+ # cmd_response = self.__send_command(command, command_string, response_size)
332
+ # if cmd_response.get('status'):
333
+ # device = self.__data.split(b'=', 1)[-1].split(b'\x00')[0]
334
+ # return device.decode()
335
+ # else:
336
+ # return ""
337
+
338
+ command = CMD_OPTIONS_RRQ
339
+ command_string = "~DeviceName\x00".b
340
+ response_size = 1024
341
+
342
+ response = self.send_command(command, command_string, response_size)
343
+
344
+ if response && response[:status]
345
+ device = @data.split("=", 2)[1]&.split("\x00")[0] || ""
346
+ return device.to_s
347
+ else
348
+ return ""
349
+ end
350
+ end
351
+
352
+ def get_face_version
353
+ # Match Python's get_face_version method exactly
354
+ # In Python:
355
+ # def get_face_version(self):
356
+ # command = const.CMD_OPTIONS_RRQ
357
+ # command_string = b'ZKFaceVersion\x00'
358
+ # response_size = 1024
359
+ # cmd_response = self.__send_command(command, command_string, response_size)
360
+ # if cmd_response.get('status'):
361
+ # response = self.__data.split(b'=', 1)[-1].split(b'\x00')[0]
362
+ # return safe_cast(response, int, 0) if response else 0
363
+ # else:
364
+ # return None
365
+
366
+ command = CMD_OPTIONS_RRQ
367
+ command_string = "ZKFaceVersion\x00".b
368
+ response_size = 1024
369
+
370
+ response = self.send_command(command, command_string, response_size)
371
+
372
+ if response && response[:status]
373
+ version = @data.split("=", 2)[1]&.split("\x00")[0] || ""
374
+ return version.to_i rescue 0
375
+ else
376
+ return nil
377
+ end
378
+ end
379
+
380
+ def get_extend_fmt
381
+ # Match Python's get_extend_fmt method exactly
382
+ # In Python:
383
+ # def get_extend_fmt(self):
384
+ # command = const.CMD_OPTIONS_RRQ
385
+ # command_string = b'~ExtendFmt\x00'
386
+ # response_size = 1024
387
+ # cmd_response = self.__send_command(command, command_string, response_size)
388
+ # if cmd_response.get('status'):
389
+ # fmt = (self.__data.split(b'=', 1)[-1].split(b'\x00')[0])
390
+ # return safe_cast(fmt, int, 0) if fmt else 0
391
+ # else:
392
+ # self._clear_error(command_string)
393
+ # return None
394
+
395
+ command = CMD_OPTIONS_RRQ
396
+ command_string = "~ExtendFmt\x00".b
397
+ response_size = 1024
398
+
399
+ response = self.send_command(command, command_string, response_size)
400
+
401
+ if response && response[:status]
402
+ fmt = @data.split("=", 2)[1]&.split("\x00")[0] || ""
403
+ return fmt.to_i rescue 0
404
+ else
405
+ # In Python, this would call self._clear_error(command_string)
406
+ # We don't have that method, so we'll just return nil
407
+ return nil
408
+ end
409
+ end
410
+
411
+ def get_platform
412
+ # Match Python's get_platform method exactly
413
+ # In Python:
414
+ # def get_platform(self):
415
+ # command = const.CMD_OPTIONS_RRQ
416
+ # command_string = b'~Platform\x00'
417
+ # response_size = 1024
418
+ # cmd_response = self.__send_command(command, command_string, response_size)
419
+ # if cmd_response.get('status'):
420
+ # platform = self.__data.split(b'=', 1)[-1].split(b'\x00')[0]
421
+ # platform = platform.replace(b'=', b'')
422
+ # return platform.decode()
423
+ # else:
424
+ # raise ZKErrorResponse("Can't read platform name")
425
+
426
+ command = CMD_OPTIONS_RRQ
427
+ command_string = "~Platform\x00".b
428
+ response_size = 1024
429
+
430
+ response = self.send_command(command, command_string, response_size)
431
+
432
+ if response && response[:status]
433
+ platform = @data.split("=", 2)[1]&.split("\x00")[0] || ""
434
+ platform = platform.gsub("=", "")
435
+ return platform.to_s
436
+ else
437
+ raise RBZK::ZKErrorResponse, "Can't read platform name"
438
+ end
439
+ end
440
+
441
+ def get_fp_version
442
+ # Match Python's get_fp_version method exactly
443
+ # In Python:
444
+ # def get_fp_version(self):
445
+ # command = const.CMD_OPTIONS_RRQ
446
+ # command_string = b'~ZKFPVersion\x00'
447
+ # response_size = 1024
448
+ # cmd_response = self.__send_command(command, command_string, response_size)
449
+ # if cmd_response.get('status'):
450
+ # response = self.__data.split(b'=', 1)[-1].split(b'\x00')[0]
451
+ # response = response.replace(b'=', b'')
452
+ # return safe_cast(response, int, 0) if response else 0
453
+ # else:
454
+ # raise ZKErrorResponse("can't read fingerprint version")
455
+
456
+ command = CMD_OPTIONS_RRQ
457
+ command_string = "~ZKFPVersion\x00".b
458
+ response_size = 1024
459
+
460
+ response = self.send_command(command, command_string, response_size)
461
+
462
+ if response && response[:status]
463
+ version = @data.split("=", 2)[1]&.split("\x00")[0] || ""
464
+ version = version.gsub("=", "")
465
+ return version.to_i rescue 0
466
+ else
467
+ raise RBZK::ZKErrorResponse, "Can't read fingerprint version"
468
+ end
469
+ end
470
+
471
+ def restart
472
+ self.send_command(CMD_RESTART)
473
+ self.recv_reply
474
+ true
475
+ end
476
+
477
+ def poweroff
478
+ self.send_command(CMD_POWEROFF)
479
+ self.recv_reply
480
+ true
481
+ end
482
+
483
+ def test_voice(index = 0)
484
+ # Match Python's test_voice method exactly
485
+ # In Python:
486
+ # def test_voice(self, index=0):
487
+ # command = const.CMD_TESTVOICE
488
+ # command_string = pack("I", index)
489
+ # cmd_response = self.__send_command(command, command_string)
490
+ # if cmd_response.get('status'):
491
+ # return True
492
+ # else:
493
+ # return False
494
+
495
+ command_string = [ index ].pack('L<')
496
+ response = self.send_command(CMD_TESTVOICE, command_string)
497
+
498
+ if response && response[:status]
499
+ return true
500
+ else
501
+ return false
502
+ end
503
+ end
504
+
505
+ # Helper method to read data with buffer, similar to Python's read_with_buffer
506
+ def read_with_buffer(command, fct = 0, ext = 0)
507
+ # Match Python's read_with_buffer method exactly
508
+ # In Python:
509
+ # def read_with_buffer(self, command, fct=0 ,ext=0):
510
+ # """
511
+ # Test read info with buffered command (ZK6: 1503)
512
+ # """
513
+ # if self.tcp:
514
+ # MAX_CHUNK = 0xFFc0
515
+ # else:
516
+ # MAX_CHUNK = 16 * 1024
517
+ # command_string = pack('<bhii', 1, command, fct, ext)
518
+ # if self.verbose: print ("rwb cs", command_string)
519
+ # response_size = 1024
520
+ # data = []
521
+ # start = 0
522
+ # cmd_response = self.__send_command(const._CMD_PREPARE_BUFFER, command_string, response_size)
523
+ # if not cmd_response.get('status'):
524
+ # raise ZKErrorResponse("RWB Not supported")
525
+ # if cmd_response['code'] == const.CMD_DATA:
526
+ # if self.tcp:
527
+ # if self.verbose: print ("DATA! is {} bytes, tcp length is {}".format(len(self.__data), self.__tcp_length))
528
+ # if len(self.__data) < (self.__tcp_length - 8):
529
+ # need = (self.__tcp_length - 8) - len(self.__data)
530
+ # if self.verbose: print ("need more data: {}".format(need))
531
+ # more_data = self.__recieve_raw_data(need)
532
+ # return b''.join([self.__data, more_data]), len(self.__data) + len(more_data)
533
+ # else:
534
+ # if self.verbose: print ("Enough data")
535
+ # size = len(self.__data)
536
+ # return self.__data, size
537
+ # else:
538
+ # size = len(self.__data)
539
+ # return self.__data, size
540
+ # size = unpack('I', self.__data[1:5])[0]
541
+ # if self.verbose: print ("size fill be %i" % size)
542
+ # remain = size % MAX_CHUNK
543
+ # packets = (size-remain) // MAX_CHUNK # should be size /16k
544
+ # if self.verbose: print ("rwb: #{} packets of max {} bytes, and extra {} bytes remain".format(packets, MAX_CHUNK, remain))
545
+ # for _wlk in range(packets):
546
+ # data.append(self.__read_chunk(start,MAX_CHUNK))
547
+ # start += MAX_CHUNK
548
+ # if remain:
549
+ # data.append(self.__read_chunk(start, remain))
550
+ # start += remain
551
+ # self.free_data()
552
+ # if self.verbose: print ("_read w/chunk %i bytes" % start)
553
+ # return b''.join(data), start
554
+
555
+ if @verbose
556
+ puts "Reading data with buffer: command=#{command}, fct=#{fct}, ext=#{ext}"
557
+ end
558
+
559
+ # Set max chunk size based on connection type
560
+ max_chunk = @tcp ? 0xFFc0 : 16 * 1024
561
+
562
+ # In Python: command_string = pack('<bhii', 1, command, fct, ext)
563
+ # Note: In Python, the format '<bhii' means:
564
+ # < - little endian
565
+ # b - signed char (1 byte)
566
+ # h - short (2 bytes)
567
+ # i - int (4 bytes)
568
+ # i - int (4 bytes)
569
+ # In Ruby, we need to use:
570
+ # c - signed char (1 byte) to match Python's 'b'
571
+ # s - short (2 bytes)
572
+ # l - long (4 bytes)
573
+ # l - long (4 bytes)
574
+ # with < for little-endian
575
+ command_string = [ 1, command, fct, ext ].pack('cs<l<l<')
576
+
577
+ if @verbose
578
+ puts "Command string: #{python_format(command_string)}"
579
+ end
580
+
581
+ # In Python: cmd_response = self.__send_command(const._CMD_PREPARE_BUFFER, command_string, response_size)
582
+ # Note: In Python, const._CMD_PREPARE_BUFFER is 1503
583
+ response_size = 1024
584
+ data = []
585
+ start = 0
586
+ response = self.send_command(CMD_PREPARE_BUFFER, command_string, response_size)
587
+
588
+ if !response || !response[:status]
589
+ raise RBZK::ZKErrorResponse, "Read with buffer not supported"
590
+ end
591
+
592
+ # Get data from the response
593
+ data = @data
594
+
595
+ if @verbose
596
+ puts "Received #{data.size} bytes of data"
597
+ end
598
+
599
+ # Check if we need more data
600
+ if response[:code] == CMD_DATA
601
+ if @tcp
602
+ if @verbose
603
+ puts "DATA! is #{data.size} bytes, tcp length is #{@tcp_length}"
604
+ end
605
+
606
+ if data.size < (@tcp_length - 8)
607
+ need = (@tcp_length - 8) - data.size
608
+ if @verbose
609
+ puts "need more data: #{need}"
610
+ end
611
+
612
+ # In Python: more_data = self.__recieve_raw_data(need)
613
+ more_data = receive_raw_data(need)
614
+
615
+ if @verbose
616
+ puts "Read #{more_data.size} more bytes"
617
+ end
618
+
619
+ # Combine the data
620
+ result = data + more_data
621
+ return result, data.size + more_data.size
622
+ else
623
+ if @verbose
624
+ puts "Enough data"
625
+ end
626
+ size = data.size
627
+ return data, size
628
+ end
629
+ else
630
+ size = data.size
631
+ return data, size
632
+ end
633
+ end
634
+
635
+ # Get the size from the first 4 bytes
636
+ # In Python: size = unpack('I', self.__data[1:5])[0]
637
+ # In Ruby, 'L<' is an unsigned long (4 bytes) in little-endian format, which matches Python's 'I'
638
+ size = data[1..4].unpack('L<')[0]
639
+
640
+ if @verbose
641
+ puts "size fill be #{size}"
642
+ end
643
+
644
+ # Calculate chunks
645
+ remain = size % max_chunk
646
+ # In Python: packets = (size-remain) // MAX_CHUNK # should be size /16k
647
+ # In Ruby, we need to use integer division to match Python's // operator
648
+ packets = (size - remain).div(max_chunk)
649
+
650
+ if @verbose
651
+ puts "rwb: ##{packets} packets of max #{max_chunk} bytes, and extra #{remain} bytes remain"
652
+ end
653
+
654
+ # Read chunks
655
+ result_data = []
656
+ start = 0
657
+
658
+ packets.times do
659
+ if @verbose
660
+ puts "recieve chunk: prepare data size is #{max_chunk}"
661
+ end
662
+ chunk = read_chunk(start, max_chunk)
663
+ result_data << chunk
664
+ start += max_chunk
665
+ end
666
+
667
+ if remain > 0
668
+ if @verbose
669
+ puts "recieve chunk: prepare data size is #{remain}"
670
+ end
671
+ chunk = read_chunk(start, remain)
672
+ result_data << chunk
673
+ start += remain
674
+ end
675
+
676
+ # Free data (equivalent to Python's self.free_data())
677
+ free_data
678
+
679
+ if @verbose
680
+ puts "_read w/chunk #{start} bytes"
681
+ end
682
+
683
+ # In Python: return b''.join(data), start
684
+ result = result_data.join
685
+ return result, start
686
+ end
687
+
688
+ # Helper method to get data size from the current data
689
+ def get_data_size
690
+ # Match Python's __get_data_size method exactly
691
+ # In Python:
692
+ # def __get_data_size(self):
693
+ # """internal function to get data size from the packet"""
694
+ # if len(self.__data) >= 4:
695
+ # size = unpack('I', self.__data[:4])[0]
696
+ # return size
697
+ # else:
698
+ # return 0
699
+
700
+ if @data && @data.size >= 4
701
+ size = @data[0...4].unpack('L<')[0]
702
+ return size
703
+ else
704
+ return 0
705
+ end
706
+ end
707
+
708
+ # Helper method to test TCP header
709
+ def test_tcp_top(data)
710
+ # Match Python's __test_tcp_top method exactly
711
+ # In Python:
712
+ # def __test_tcp_top(self, packet):
713
+ # """test a TCP packet header"""
714
+ # if not packet:
715
+ # return False
716
+ # if len(packet) < 8:
717
+ # self.__tcp_length = 0
718
+ # self.__response = const.CMD_TCP_STILL_ALIVE
719
+ # return True
720
+ #
721
+ # if len(packet) < 8:
722
+ # self.__tcp_length = 0
723
+ # self.__response = const.CMD_TCP_STILL_ALIVE
724
+ # return True
725
+ # top, self.__session_id, self.__reply_id, self.__tcp_length = unpack('<HHHI', packet[:8])
726
+ # self.__response = top
727
+ # if self.verbose: print ("tcp top is {}, session id is {}, reply id is {}, tcp length is {}".format(
728
+ # self.__response, self.__session_id, self.__reply_id, self.__tcp_length))
729
+ # return True
730
+
731
+ if !data || data.empty?
732
+ return false
733
+ end
734
+
735
+ if data.size < 8
736
+ @tcp_length = 0
737
+ @response = CMD_TCP_STILL_ALIVE
738
+ return true
739
+ end
740
+
741
+ top, @session_id, @reply_id, @tcp_length = data[0...8].unpack('S<S<S<L<')
742
+ @response = top
743
+
744
+ if @verbose
745
+ puts "tcp top is #{@response}, session id is #{@session_id}, reply id is #{@reply_id}, tcp length is #{@tcp_length}"
746
+ end
747
+
748
+ return true
749
+ end
750
+
751
+ # Helper method to receive TCP data
752
+ def receive_tcp_data(data_recv, size)
753
+ data = []
754
+ tcp_length = test_tcp_top(data_recv)
755
+
756
+ puts "tcp_length #{tcp_length}, size #{size}" if @verbose
757
+
758
+ if tcp_length <= 0
759
+ puts "Incorrect tcp packet" if @verbose
760
+ return nil, "".b
761
+ end
762
+
763
+ if (tcp_length - 8) < size
764
+ puts "tcp length too small... retrying" if @verbose
765
+
766
+ # Recursive call to handle smaller packet
767
+ resp, bh = receive_tcp_data(data_recv, tcp_length - 8)
768
+ data << resp if resp
769
+ size -= resp.size
770
+
771
+ puts "new tcp DATA packet to fill misssing #{size}" if @verbose
772
+
773
+ # Get more data to fill missing
774
+ data_recv = bh + @socket.recv(size + 16)
775
+
776
+ puts "new tcp DATA starting with #{data_recv.size} bytes" if @verbose
777
+
778
+ # Another recursive call with new data
779
+ resp, bh = receive_tcp_data(data_recv, size)
780
+ data << resp
781
+
782
+ puts "for misssing #{size} recieved #{resp ? resp.size : 0} with extra #{bh.size}" if @verbose
783
+
784
+ return data.join, bh
785
+ end
786
+
787
+ received = data_recv.size
788
+
789
+ puts "received #{received}, size #{size}" if @verbose
790
+
791
+ # In Python: response = unpack('HHHH', data_recv[8:16])[0]
792
+ # This unpacks 4 shorts (8 bytes) but only uses the first one
793
+ response = data_recv[8...16].unpack('S<S<S<S<')[0]
794
+
795
+ if received >= (size + 32)
796
+ if response == CMD_DATA
797
+ resp = data_recv[16...(size + 16)]
798
+
799
+ puts "resp complete len #{resp.size}" if @verbose
800
+
801
+ return resp, data_recv[(size + 16)..]
802
+ else
803
+ puts "incorrect response!!! #{response}" if @verbose
804
+
805
+ return nil, "".b
806
+ end
807
+ else
808
+ puts "try DATA incomplete (actual valid #{received - 16})" if @verbose
809
+
810
+ data << data_recv[16...(size + 16)]
811
+ size -= received - 16
812
+ broken_header = "".b
813
+
814
+ if size < 0
815
+ broken_header = data_recv[size..]
816
+
817
+ if @verbose
818
+ puts "broken: #{broken_header.bytes.map { |b| format('%02x', b) }.join}"
819
+ end
820
+ end
821
+
822
+ if size > 0
823
+ data_recv = receive_raw_data(size)
824
+ data << data_recv
825
+ end
826
+
827
+ [ data.join, broken_header ]
828
+ end
829
+ end
830
+
831
+ # Helper method to receive a chunk (like Python's __recieve_chunk)
832
+ def receive_chunk
833
+ if @response == CMD_DATA
834
+ if @tcp
835
+ puts "_rc_DATA! is #{@data.size} bytes, tcp length is #{@tcp_length}" if @verbose
836
+
837
+ if @data.size < (@tcp_length - 8)
838
+ need = (@tcp_length - 8) - @data.size
839
+ puts "need more data: #{need}" if @verbose
840
+ more_data = receive_raw_data(need)
841
+ return @data + more_data
842
+ else
843
+ puts "Enough data" if @verbose
844
+ return @data
845
+ end
846
+ else
847
+ puts "_rc len is #{@data.size}" if @verbose
848
+ return @data
849
+ end
850
+ elsif @response == CMD_PREPARE_DATA
851
+ data = []
852
+ size = get_data_size
853
+
854
+ puts "recieve chunk: prepare data size is #{size}" if @verbose
855
+
856
+ if @tcp
857
+ if @data.size >= (8 + size)
858
+ data_recv = @data[8..]
859
+ else
860
+ # [80, 80, 130, 125, 92, 7, 0, 0, 221, 5, 142, 172, 0, 0, 4, 0, 80, 7, 0, 0, 1, 0, 14, 0, 0, 0, 0, 0, 0, 0, 0, 65, 98, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 2, 0, 14, 0, 0, 0, 0, 0, 0, 0, 0, 65, 110, 97, 115, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 50, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 111, 110, 100, 111, 115, 44, 65, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 51, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 116, 97, 0, 111, 115, 44, 65, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 52, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 97, 121, 115, 0, 115, 44, 65, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 53, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 82, 97, 119, 97, 110, 0, 44, 65, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 54, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 74, 101, 110, 97, 110, 0, 44, 65, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 55, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 70, 97, 114, 97, 104, 0, 44, 65, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 56, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 97, 98, 114, 101, 101, 110, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 57, 0, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 97, 101, 101, 100, 0, 110, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 48, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 71, 111, 102, 114, 97, 110, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 49, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 68, 97, 110, 105, 97, 0, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 50, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 13, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 97, 109, 105, 97, 0, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 51, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 82, 101, 101, 109, 0, 0, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 52, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 87, 97, 108, 97, 97, 0, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 53, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 82, 97, 110, 101, 101, 109, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 54, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 79, 108, 97, 0, 101, 109, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 55, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 97, 114, 97, 104, 0, 0, 0, 98, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 56, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 111, 110, 100, 111, 115, 72, 65, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 57, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 111, 0, 100, 111, 115, 72, 65, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 52, 53, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 119, 115, 0, 111, 115, 72, 65, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 49, 52, 54, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 97, 109, 97, 114, 97, 0, 65, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 52, 53, 50, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 84, 97, 115, 110, 101, 101, 109, 0, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 50, 48, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 69, 115, 114, 97, 97, 0, 109, 0, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 50, 49, 0, 0, 1, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 97, 114, 97, 106, 0, 109, 0, 0, 117, 107, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 50, 49, 51, 53, 0, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 26, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 108, 97, 97, 110, 97, 115, 115, 101, 114, 0, 104, 100, 97, 105, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 50, 50, 0, 53, 0, 0, 0, 0, 25, 0, 0, 0, 0, 254, 6, 119, 255, 255, 255, 255, 212, 10, 159, 127, 80, 80, 130, 125, 8, 0, 0, 0, 208, 7, 73, 81, 226, 166, 4, 0]
861
+ data_recv = @data[8..] + @socket.recv(size + 32)
862
+ end
863
+
864
+ puts "data_recv: #{python_format(data_recv)}" if @verbose
865
+
866
+ # hault_for_debuggg_DO_NOT_REMOVE
867
+ resp, broken_header = receive_tcp_data(data_recv, size)
868
+ data << resp if resp
869
+
870
+ # get CMD_ACK_OK
871
+ if broken_header.size < 16
872
+ data_recv = broken_header + @socket.recv(16)
873
+ else
874
+ data_recv = broken_header
875
+ end
876
+
877
+ if data_recv.size < 16
878
+ puts "trying to complete broken ACK #{data_recv.size} /16"
879
+ if @verbose
880
+ puts "data_recv: #{data_recv.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
881
+ end
882
+ data_recv += @socket.recv(16 - data_recv.size) # TODO: CHECK HERE_!
883
+ end
884
+
885
+ if !test_tcp_top(data_recv)
886
+ if @verbose
887
+ puts "invalid chunk tcp ACK OK"
888
+ end
889
+ return nil
890
+ end
891
+
892
+ # In Python: response = unpack('HHHH', data_recv[8:16])[0]
893
+ # This unpacks 4 shorts (8 bytes) but only uses the first one
894
+ # In Ruby, we need to use 'S<4' to unpack 4 shorts in little-endian format
895
+ response = data_recv[8...16].unpack('S<4')[0]
896
+
897
+ if response == CMD_ACK_OK
898
+ if @verbose
899
+ puts "chunk tcp ACK OK!"
900
+ end
901
+ return data.join
902
+ end
903
+
904
+ if @verbose
905
+ puts "bad response #{format_as_python_bytes(data_recv)}"
906
+ puts "data: #{data.map { |d| format_as_python_bytes(d) }.join(', ')}"
907
+ end
908
+
909
+ return nil
910
+ else
911
+ # Non-TCP implementation
912
+ loop do
913
+ data_recv = @socket.recv(1024 + 8)
914
+ response = data_recv[0...8].unpack('S<S<S<S<')[0]
915
+
916
+ if @verbose
917
+ puts "# packet response is: #{response}"
918
+ end
919
+
920
+ if response == CMD_DATA
921
+ data << data_recv[8..]
922
+ size -= 1024
923
+ elsif response == CMD_ACK_OK
924
+ break
925
+ else
926
+ if @verbose
927
+ puts "broken!"
928
+ end
929
+ break
930
+ end
931
+
932
+ if @verbose
933
+ puts "still needs #{size}"
934
+ end
935
+ end
936
+
937
+ return data.join
938
+ end
939
+ else
940
+ if @verbose
941
+ puts "invalid response #{@response}"
942
+ end
943
+ return nil
944
+ end
945
+ end
946
+
947
+ # Helper method to receive raw data (like Python's __recieve_raw_data)
948
+ def receive_raw_data(size)
949
+ # Match Python's __recieve_raw_data method exactly
950
+ # In Python:
951
+ # def __recieve_raw_data(self, size):
952
+ # """ partial data ? """
953
+ # data = []
954
+ # if self.verbose: print ("expecting {} bytes raw data".format(size))
955
+ # while size > 0:
956
+ # data_recv = self.__sock.recv(size)
957
+ # recieved = len(data_recv)
958
+ # if self.verbose: print ("partial recv {}".format(recieved))
959
+ # if recieved < 100 and self.verbose: print (" recv {}".format(codecs.encode(data_recv, 'hex')))
960
+ # data.append(data_recv)
961
+ # size -= recieved
962
+ # if self.verbose: print ("still need {}".format(size))
963
+ # return b''.join(data)
964
+
965
+ data = []
966
+ if @verbose
967
+ puts "expecting #{size} bytes raw data"
968
+ end
969
+
970
+ while size > 0
971
+ data_recv = @socket.recv(size)
972
+ received = data_recv.size
973
+
974
+ if @verbose
975
+ puts "partial recv #{received}"
976
+ if received < 100
977
+ puts " recv #{data_recv.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
978
+ end
979
+ end
980
+
981
+ data << data_recv
982
+ size -= received
983
+
984
+ if @verbose
985
+ puts "still need #{size}"
986
+ end
987
+ end
988
+
989
+ data.join
990
+ end
991
+
992
+ # Helper method to clear buffer (like Python's free_data)
993
+ def free_data
994
+ # Match Python's free_data method exactly
995
+ # In Python:
996
+ # def free_data(self):
997
+ # """
998
+ # clear buffer
999
+ #
1000
+ # :return: bool
1001
+ # """
1002
+ # command = const.CMD_FREE_DATA
1003
+ # cmd_response = self.__send_command(command)
1004
+ # if cmd_response.get('status'):
1005
+ # return True
1006
+ # else:
1007
+ # raise ZKErrorResponse("can't free data")
1008
+
1009
+ command = CMD_FREE_DATA
1010
+ response = self.send_command(command)
1011
+
1012
+ if response && response[:status]
1013
+ return true
1014
+ else
1015
+ raise RBZK::ZKErrorResponse, "Can't free data"
1016
+ end
1017
+ end
1018
+
1019
+ # Helper method to read a chunk of data
1020
+ def read_chunk(start, size)
1021
+ # Match Python's __read_chunk method exactly
1022
+ # In Python:
1023
+ # def __read_chunk(self, start, size):
1024
+ # """
1025
+ # read a chunk from buffer
1026
+ # """
1027
+ # for _retries in range(3):
1028
+ # command = const._CMD_READ_BUFFER
1029
+ # command_string = pack('<ii', start, size)
1030
+ # if self.tcp:
1031
+ # response_size = size + 32
1032
+ # else:
1033
+ # response_size = 1024 + 8
1034
+ # cmd_response = self.__send_command(command, command_string, response_size)
1035
+ # data = self.__recieve_chunk()
1036
+ # if data is not None:
1037
+ # return data
1038
+ # else:
1039
+ # raise ZKErrorResponse("can't read chunk %i:[%i]" % (start, size))
1040
+
1041
+ if @verbose
1042
+ puts "Reading chunk: start=#{start}, size=#{size}"
1043
+ end
1044
+
1045
+ 3.times do |_retries|
1046
+ # In Python: command = const._CMD_READ_BUFFER (which is 1504)
1047
+ # In Ruby, we should use CMD_READ_BUFFER (1504) instead of CMD_READFILE_DATA (81)
1048
+ command = 1504 # CMD_READ_BUFFER
1049
+
1050
+ # In Python: command_string = pack('<ii', start, size)
1051
+ command_string = [ start, size ].pack('l<l<')
1052
+
1053
+ # In Python: response_size = size + 32 if self.tcp else 1024 + 8
1054
+ response_size = @tcp ? size + 32 : 1024 + 8
1055
+
1056
+ # In Python: cmd_response = self.__send_command(command, command_string, response_size)
1057
+ response = self.send_command(command, command_string, response_size)
1058
+
1059
+ if !response || !response[:status]
1060
+ if @verbose
1061
+ puts "Failed to read chunk on attempt #{_retries + 1}"
1062
+ end
1063
+ next
1064
+ end
1065
+
1066
+ # In Python: data = self.__recieve_chunk()
1067
+ data = receive_chunk
1068
+
1069
+ if data
1070
+ if @verbose
1071
+ puts "Received chunk of #{data.size} bytes"
1072
+ end
1073
+
1074
+ return data
1075
+ end
1076
+ end
1077
+
1078
+ # If we get here, all retries failed
1079
+ raise RBZK::ZKErrorResponse, "can't read chunk #{start}:[#{size}]"
1080
+ end
1081
+
1082
+ def get_users
1083
+ # Read sizes
1084
+ self.read_sizes
1085
+
1086
+ puts "Device has #{@users} users" if @verbose
1087
+
1088
+ # If no users, return empty array
1089
+ if @users == 0
1090
+ @next_uid = 1
1091
+ @next_user_id = '1'
1092
+ return []
1093
+ end
1094
+
1095
+ users = []
1096
+ max_uid = 0
1097
+ userdata, size = self.read_with_buffer(CMD_USERTEMP_RRQ, FCT_USER)
1098
+ puts "user size #{size} (= #{userdata.length})" if @verbose
1099
+
1100
+ if size <= 4
1101
+ puts "WRN: missing user data"
1102
+ return []
1103
+ end
1104
+
1105
+ total_size = userdata[0, 4].unpack1('L<')
1106
+ @user_packet_size = total_size / @users
1107
+
1108
+ if ![ 28, 72 ].include?(@user_packet_size)
1109
+ puts "WRN packet size would be #{@user_packet_size}" if @verbose
1110
+ end
1111
+
1112
+ userdata = userdata[4..-1]
1113
+
1114
+ if @user_packet_size == 28
1115
+ while userdata.length >= 28
1116
+ uid, privilege, password, name, card, group_id, timezone, user_id = userdata.ljust(28, "\x00")[0, 28].unpack('S<Ca5a8L<xCs<L<')
1117
+ max_uid = uid if uid > max_uid
1118
+ password = password.split("\x00").first&.force_encoding(@encoding)&.encode('UTF-8', invalid: :replace)
1119
+ name = name.split("\x00").first&.force_encoding(@encoding)&.encode('UTF-8', invalid: :replace)&.strip
1120
+ group_id = group_id.to_s
1121
+ user_id = user_id.to_s
1122
+ name = "NN-#{user_id}" if !name
1123
+ user = User.new(uid, name, privilege, password, group_id, user_id, card)
1124
+ users << user
1125
+ puts "[6]user: #{uid}, #{privilege}, #{password}, #{name}, #{card}, #{group_id}, #{timezone}, #{user_id}" if @verbose
1126
+ userdata = userdata[28..-1]
1127
+ end
1128
+ else
1129
+ while userdata.length >= 72
1130
+ uid, privilege, password, name, card, group_id, user_id = userdata.ljust(72, "\x00")[0, 72].unpack('S<Ca8a24L<xa7xa24')
1131
+ max_uid = uid if uid > max_uid
1132
+ password = password.split("\x00").first&.force_encoding(@encoding)&.encode('UTF-8', invalid: :replace)
1133
+ name = name.split("\x00").first&.force_encoding(@encoding)&.encode('UTF-8', invalid: :replace)&.strip
1134
+ group_id = group_id.split("\x00").first&.force_encoding(@encoding)&.encode('UTF-8', invalid: :replace)&.strip
1135
+ user_id = user_id.split("\x00").first&.force_encoding(@encoding)&.encode('UTF-8', invalid: :replace)
1136
+ name = "NN-#{user_id}" if !name
1137
+ user = User.new(uid, name, privilege, password, group_id, user_id, card)
1138
+ users << user
1139
+ userdata = userdata[72..-1]
1140
+ end
1141
+ end
1142
+
1143
+ max_uid += 1
1144
+ @next_uid = max_uid
1145
+ @next_user_id = max_uid.to_s
1146
+
1147
+ loop do
1148
+ if users.any? { |u| u.user_id == @next_user_id }
1149
+ max_uid += 1
1150
+ @next_user_id = max_uid.to_s
1151
+ else
1152
+ break
1153
+ end
1154
+ end
1155
+
1156
+ users
1157
+ end
1158
+
1159
+ def get_attendance_logs
1160
+ # Match Python's get_attendance method exactly
1161
+ # In Python:
1162
+ # def get_attendance(self):
1163
+ # self.read_sizes()
1164
+ # if self.records == 0:
1165
+ # return []
1166
+ # users = self.get_users()
1167
+ # if self.verbose: print (users)
1168
+ # attendances = []
1169
+ # attendance_data, size = self.read_with_buffer(const.CMD_ATTLOG_RRQ)
1170
+ # if size < 4:
1171
+ # if self.verbose: print ("WRN: no attendance data")
1172
+ # return []
1173
+ # total_size = unpack("I", attendance_data[:4])[0]
1174
+ # record_size = total_size // self.records
1175
+ # if self.verbose: print ("record_size is ", record_size)
1176
+ # attendance_data = attendance_data[4:]
1177
+ # if record_size == 8:
1178
+ # while len(attendance_data) >= 8:
1179
+ # uid, status, timestamp, punch = unpack('HB4sB', attendance_data.ljust(8, b'\x00')[:8])
1180
+ # if self.verbose: print (codecs.encode(attendance_data[:8], 'hex'))
1181
+ # attendance_data = attendance_data[8:]
1182
+ # tuser = list(filter(lambda x: x.uid == uid, users))
1183
+ # if not tuser:
1184
+ # user_id = str(uid)
1185
+ # else:
1186
+ # user_id = tuser[0].user_id
1187
+ # timestamp = self.__decode_time(timestamp)
1188
+ # attendance = Attendance(user_id, timestamp, status, punch, uid)
1189
+ # attendances.append(attendance)
1190
+ # elif record_size == 16:
1191
+ # while len(attendance_data) >= 16:
1192
+ # user_id, timestamp, status, punch, reserved, workcode = unpack('<I4sBB2sI', attendance_data.ljust(16, b'\x00')[:16])
1193
+ # user_id = str(user_id)
1194
+ # if self.verbose: print(codecs.encode(attendance_data[:16], 'hex'))
1195
+ # attendance_data = attendance_data[16:]
1196
+ # tuser = list(filter(lambda x: x.user_id == user_id, users))
1197
+ # if not tuser:
1198
+ # if self.verbose: print("no uid {}", user_id)
1199
+ # uid = str(user_id)
1200
+ # tuser = list(filter(lambda x: x.uid == user_id, users))
1201
+ # if not tuser:
1202
+ # uid = str(user_id)
1203
+ # else:
1204
+ # uid = tuser[0].uid
1205
+ # user_id = tuser[0].user_id
1206
+ # else:
1207
+ # uid = tuser[0].uid
1208
+ # timestamp = self.__decode_time(timestamp)
1209
+ # attendance = Attendance(user_id, timestamp, status, punch, uid)
1210
+ # attendances.append(attendance)
1211
+ # else:
1212
+ # while len(attendance_data) >= 40:
1213
+ # uid, user_id, status, timestamp, punch, space = unpack('<H24sB4sB8s', attendance_data.ljust(40, b'\x00')[:40])
1214
+ # if self.verbose: print (codecs.encode(attendance_data[:40], 'hex'))
1215
+ # user_id = (user_id.split(b'\x00')[0]).decode(errors='ignore')
1216
+ # timestamp = self.__decode_time(timestamp)
1217
+ # attendance = Attendance(user_id, timestamp, status, punch, uid)
1218
+ # attendances.append(attendance)
1219
+ # attendance_data = attendance_data[record_size:]
1220
+ # return attendances
1221
+
1222
+ # First, read device sizes to get record count
1223
+ self.read_sizes
1224
+
1225
+ # If no records, return empty array
1226
+ if @records == 0
1227
+ return []
1228
+ end
1229
+
1230
+ # Get users for lookup
1231
+ users = self.get_users
1232
+
1233
+ if @verbose
1234
+ puts "Found #{users.size} users"
1235
+ end
1236
+
1237
+ logs = []
1238
+
1239
+ # Read attendance data with buffer
1240
+ attendance_data, size = self.read_with_buffer(CMD_ATTLOG_RRQ)
1241
+
1242
+ if size < 4
1243
+ if @verbose
1244
+ puts "WRN: no attendance data"
1245
+ end
1246
+ return []
1247
+ end
1248
+
1249
+ # Get total size from first 4 bytes
1250
+ total_size = attendance_data[0...4].unpack('I')[0]
1251
+
1252
+ # Calculate record size
1253
+ record_size = @records > 0 ? total_size / @records : 0
1254
+
1255
+ if @verbose
1256
+ puts "record_size is #{record_size}"
1257
+ end
1258
+
1259
+ # Remove the first 4 bytes (total size)
1260
+ attendance_data = attendance_data[4..-1]
1261
+
1262
+ if record_size == 8
1263
+ # Handle 8-byte records
1264
+ while attendance_data && attendance_data.size >= 8
1265
+ # In Python: uid, status, timestamp, punch = unpack('HB4sB', attendance_data.ljust(8, b'\x00')[:8])
1266
+ uid, status, timestamp_raw, punch = attendance_data[0...8].ljust(8, "\x00".b).unpack('S<C4sC')
1267
+
1268
+ if @verbose
1269
+ puts "Attendance data (hex): #{attendance_data[0...8].bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1270
+ end
1271
+
1272
+ attendance_data = attendance_data[8..-1]
1273
+
1274
+ # Look up user by uid
1275
+ tuser = users.find { |u| u.uid == uid }
1276
+ if !tuser
1277
+ user_id = uid.to_s
1278
+ else
1279
+ user_id = tuser.user_id
1280
+ end
1281
+
1282
+ # Decode timestamp
1283
+ timestamp = decode_time(timestamp_raw)
1284
+
1285
+ # Create attendance record
1286
+ attendance = RBZK::Attendance.new(user_id, timestamp, status, punch, uid)
1287
+ logs << attendance
1288
+ end
1289
+ elsif record_size == 16
1290
+ # Handle 16-byte records
1291
+ while attendance_data && attendance_data.size >= 16
1292
+ # In Python: user_id, timestamp, status, punch, reserved, workcode = unpack('<I4sBB2sI', attendance_data.ljust(16, b'\x00')[:16])
1293
+ user_id_raw, timestamp_raw, status, punch, reserved, workcode = attendance_data[0...16].ljust(16, "\x00".b).unpack('L<4sCCa2L<')
1294
+
1295
+ if @verbose
1296
+ puts "Attendance data (hex): #{attendance_data[0...16].bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1297
+ end
1298
+
1299
+ attendance_data = attendance_data[16..-1]
1300
+
1301
+ # Convert user_id to string
1302
+ user_id = user_id_raw.to_s
1303
+
1304
+ # Look up user by user_id and uid
1305
+ tuser = users.find { |u| u.user_id == user_id }
1306
+ if !tuser
1307
+ if @verbose
1308
+ puts "no uid #{user_id}"
1309
+ end
1310
+ uid = user_id
1311
+ tuser = users.find { |u| u.uid.to_s == user_id }
1312
+ if !tuser
1313
+ uid = user_id
1314
+ else
1315
+ uid = tuser.uid
1316
+ user_id = tuser.user_id
1317
+ end
1318
+ else
1319
+ uid = tuser.uid
1320
+ end
1321
+
1322
+ # Decode timestamp
1323
+ timestamp = decode_time(timestamp_raw)
1324
+
1325
+ # Create attendance record
1326
+ attendance = RBZK::Attendance.new(user_id, timestamp, status, punch, uid)
1327
+ logs << attendance
1328
+ end
1329
+ else
1330
+ # Handle 40-byte records (default)
1331
+ while attendance_data && attendance_data.size >= 40
1332
+ # In Python: uid, user_id, status, timestamp, punch, space = unpack('<H24sB4sB8s', attendance_data.ljust(40, b'\x00')[:40])
1333
+ uid, user_id_raw, status, timestamp_raw, punch, space = attendance_data[0...40].ljust(40, "\x00".b).unpack('S<a24Ca4Ca8')
1334
+
1335
+ if @verbose
1336
+ puts "Attendance data (hex): #{attendance_data[0...40].bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1337
+ end
1338
+
1339
+ # Extract user_id from null-terminated string
1340
+ user_id = user_id_raw.split("\x00")[0].to_s
1341
+
1342
+ # Decode timestamp
1343
+ timestamp = decode_time(timestamp_raw)
1344
+
1345
+ # Create attendance record
1346
+ attendance = RBZK::Attendance.new(user_id, timestamp, status, punch, uid)
1347
+ logs << attendance
1348
+
1349
+ attendance_data = attendance_data[record_size..-1]
1350
+ end
1351
+ end
1352
+
1353
+ logs
1354
+ end
1355
+
1356
+ # Decode a timestamp retrieved from the timeclock
1357
+ # Match Python's __decode_time method exactly
1358
+ # In Python:
1359
+ # def __decode_time(self, t):
1360
+ # t = unpack("<I", t)[0]
1361
+ # second = t % 60
1362
+ # t = t // 60
1363
+ #
1364
+ # minute = t % 60
1365
+ # t = t // 60
1366
+ #
1367
+ # hour = t % 24
1368
+ # t = t // 24
1369
+ #
1370
+ # day = t % 31 + 1
1371
+ # t = t // 31
1372
+ #
1373
+ # month = t % 12 + 1
1374
+ # t = t // 12
1375
+ #
1376
+ # year = t + 2000
1377
+ #
1378
+ # d = datetime(year, month, day, hour, minute, second)
1379
+ #
1380
+ # return d
1381
+ def decode_time(t)
1382
+ # Convert binary timestamp to integer
1383
+ t = t.unpack("L<")[0]
1384
+
1385
+ # Extract time components
1386
+ second = t % 60
1387
+ t = t / 60
1388
+
1389
+ minute = t % 60
1390
+ t = t / 60
1391
+
1392
+ hour = t % 24
1393
+ t = t / 24
1394
+
1395
+ day = t % 31 + 1
1396
+ t = t / 31
1397
+
1398
+ month = t % 12 + 1
1399
+ t = t / 12
1400
+
1401
+ year = t + 2000
1402
+
1403
+ # Create Time object
1404
+ Time.new(year, month, day, hour, minute, second)
1405
+ end
1406
+
1407
+ # Decode a timestamp in hex format (6 bytes)
1408
+ # Match Python's __decode_timehex method
1409
+ # In Python:
1410
+ # def __decode_timehex(self, timehex):
1411
+ # year, month, day, hour, minute, second = unpack("6B", timehex)
1412
+ # year += 2000
1413
+ # d = datetime(year, month, day, hour, minute, second)
1414
+ # return d
1415
+ def decode_timehex(timehex)
1416
+ # Extract time components
1417
+ year, month, day, hour, minute, second = timehex.unpack("C6")
1418
+ year += 2000
1419
+
1420
+ # Create Time object
1421
+ Time.new(year, month, day, hour, minute, second)
1422
+ end
1423
+
1424
+ # Encode a timestamp for the device
1425
+ # Match Python's __encode_time method
1426
+ # In Python:
1427
+ # def __encode_time(self, t):
1428
+ # d = (
1429
+ # ((t.year % 100) * 12 * 31 + ((t.month - 1) * 31) + t.day - 1) *
1430
+ # (24 * 60 * 60) + (t.hour * 60 + t.minute) * 60 + t.second
1431
+ # )
1432
+ # return d
1433
+ def encode_time(t)
1434
+ # Calculate encoded timestamp
1435
+ d = (
1436
+ ((t.year % 100) * 12 * 31 + ((t.month - 1) * 31) + t.day - 1) *
1437
+ (24 * 60 * 60) + (t.hour * 60 + t.minute) * 60 + t.second
1438
+ )
1439
+ d
1440
+ end
1441
+
1442
+ def get_time
1443
+ # Match Python's get_time method exactly
1444
+ # In Python:
1445
+ # def get_time(self):
1446
+ # command = const.CMD_GET_TIME
1447
+ # response_size = 1032
1448
+ # cmd_response = self.__send_command(command, b'', response_size)
1449
+ # if cmd_response.get('status'):
1450
+ # return self.__decode_time(self.__data[:4])
1451
+ # else:
1452
+ # raise ZKErrorResponse("can't get time")
1453
+
1454
+ command = CMD_GET_TIME
1455
+ response_size = 1032
1456
+ response = self.send_command(command, "", response_size)
1457
+
1458
+ if response && response[:status]
1459
+ return decode_time(@data[0...4])
1460
+ else
1461
+ raise RBZK::ZKErrorResponse, "Can't get time"
1462
+ end
1463
+ end
1464
+
1465
+ def set_time(timestamp = nil)
1466
+ # Match Python's set_time method exactly
1467
+ # In Python:
1468
+ # def set_time(self, timestamp):
1469
+ # command = const.CMD_SET_TIME
1470
+ # command_string = pack(b'I', self.__encode_time(timestamp))
1471
+ # cmd_response = self.__send_command(command, command_string)
1472
+ # if cmd_response.get('status'):
1473
+ # return True
1474
+ # else:
1475
+ # raise ZKErrorResponse("can't set time")
1476
+
1477
+ # Default to current time if not provided
1478
+ timestamp ||= Time.now
1479
+
1480
+ command = CMD_SET_TIME
1481
+ command_string = [ encode_time(timestamp) ].pack('L<')
1482
+ response = self.send_command(command, command_string)
1483
+
1484
+ if response && response[:status]
1485
+ return true
1486
+ else
1487
+ raise RBZK::ZKErrorResponse, "Can't set time"
1488
+ end
1489
+ end
1490
+
1491
+ def clear_attendance_logs
1492
+ self.send_command(CMD_CLEAR_ATTLOG)
1493
+ self.recv_reply
1494
+ true
1495
+ end
1496
+
1497
+ def clear_data
1498
+ self.send_command(CMD_CLEAR_DATA)
1499
+ self.recv_reply
1500
+ true
1501
+ end
1502
+
1503
+ # Helper method to print binary data in Python format
1504
+ def format_as_python_bytes(binary_string)
1505
+ return "b''" if binary_string.nil? || binary_string.empty?
1506
+
1507
+ result = "b'"
1508
+ binary_string.each_byte do |byte|
1509
+ case byte
1510
+ when 0x0d # Carriage return - Python shows as \r
1511
+ result += "\\r"
1512
+ when 0x0a # Line feed - Python shows as \n
1513
+ result += "\\n"
1514
+ when 0x09 # Tab - Python shows as \t
1515
+ result += "\\t"
1516
+ when 0x07 # Bell - Python can show as \a or \x07
1517
+ result += "\\x07"
1518
+ when 0x08 # Backspace - Python shows as \b
1519
+ result += "\\b"
1520
+ when 0x0c # Form feed - Python shows as \f
1521
+ result += "\\f"
1522
+ when 0x0b # Vertical tab - Python shows as \v
1523
+ result += "\\v"
1524
+ when 0x5c # Backslash - Python shows as \\
1525
+ result += "\\\\"
1526
+ when 0x27 # Single quote - Python shows as \'
1527
+ result += "\\'"
1528
+ when 0x22 # Double quote - Python shows as \"
1529
+ result += "\\\""
1530
+ when 32..126 # Printable ASCII
1531
+ result += byte.chr
1532
+ else
1533
+ # All other bytes - Python shows as \xHH
1534
+ result += "\\x#{byte.to_s(16).rjust(2, '0')}"
1535
+ end
1536
+ end
1537
+ result += "'"
1538
+ result
1539
+ end
1540
+
1541
+ # Helper method to compare binary data between Python and Ruby
1542
+ def compare_binary(binary_string, python_expected)
1543
+ ruby_formatted = format_as_python_bytes(binary_string)
1544
+
1545
+ if @verbose
1546
+ puts "Ruby binary: #{ruby_formatted}"
1547
+ puts "Python expected: #{python_expected}"
1548
+
1549
+ if ruby_formatted != python_expected
1550
+ puts "DIFFERENCE DETECTED!"
1551
+ # Show byte-by-byte comparison
1552
+ ruby_bytes = binary_string.bytes
1553
+ # Parse Python bytes string (format: b'\x01\x02')
1554
+ python_bytes = []
1555
+ python_str = python_expected[2..-2] # Remove b'' wrapper
1556
+ i = 0
1557
+ while i < python_str.length
1558
+ if python_str[i] == '\\' && python_str[i + 1] == 'x'
1559
+ # Handle \xNN format
1560
+ hex_val = python_str[i + 2..i + 3]
1561
+ python_bytes << hex_val.to_i(16)
1562
+ i += 4
1563
+ elsif python_str[i] == '\\'
1564
+ # Handle escape sequences
1565
+ case python_str[i + 1]
1566
+ when 't'
1567
+ python_bytes << 9
1568
+ when 'n'
1569
+ python_bytes << 10
1570
+ when 'r'
1571
+ python_bytes << 13
1572
+ when '\\'
1573
+ python_bytes << 92
1574
+ when "'"
1575
+ python_bytes << 39
1576
+ end
1577
+ i += 2
1578
+ else
1579
+ # Regular character
1580
+ python_bytes << python_str[i].ord
1581
+ i += 1
1582
+ end
1583
+ end
1584
+
1585
+ # Show differences
1586
+ puts "Byte-by-byte comparison:"
1587
+ max_len = [ ruby_bytes.length, python_bytes.length ].max
1588
+ (0...max_len).each do |j|
1589
+ ruby_byte = j < ruby_bytes.length ? ruby_bytes[j] : nil
1590
+ python_byte = j < python_bytes.length ? python_bytes[j] : nil
1591
+ match = ruby_byte == python_byte ? "✓" : "✗"
1592
+ puts " Byte #{j}: Ruby=#{ruby_byte.nil? ? 'nil' : "0x#{ruby_byte.to_s(16).rjust(2, '0')}"}, Python=#{python_byte.nil? ? 'nil' : "0x#{python_byte.to_s(16).rjust(2, '0')}"} #{match}"
1593
+ end
1594
+ else
1595
+ puts "Binary data matches exactly!"
1596
+ end
1597
+ end
1598
+
1599
+ ruby_formatted == python_expected
1600
+ end
1601
+
1602
+ # Alias for backward compatibility
1603
+ alias python_format format_as_python_bytes
1604
+
1605
+ # Helper method to debug binary data in Python format only
1606
+ def debug_python_binary(label, data)
1607
+ puts "#{label}: #{format_as_python_bytes(data)}"
1608
+ end
1609
+
1610
+ def read_sizes
1611
+ # Match Python's read_sizes method exactly
1612
+ # In Python:
1613
+ # def read_sizes(self):
1614
+ # command = const.CMD_GET_FREE_SIZES
1615
+ # response_size = 1024
1616
+ # cmd_response = self.__send_command(command,b'', response_size)
1617
+ # if cmd_response.get('status'):
1618
+ # if self.verbose: print(codecs.encode(self.__data,'hex'))
1619
+ # size = len(self.__data)
1620
+ # if len(self.__data) >= 80:
1621
+ # fields = unpack('20i', self.__data[:80])
1622
+ # self.users = fields[4]
1623
+ # self.fingers = fields[6]
1624
+ # self.records = fields[8]
1625
+ # self.dummy = fields[10] #???
1626
+ # self.cards = fields[12]
1627
+ # self.fingers_cap = fields[14]
1628
+ # self.users_cap = fields[15]
1629
+ # self.rec_cap = fields[16]
1630
+ # self.fingers_av = fields[17]
1631
+ # self.users_av = fields[18]
1632
+ # self.rec_av = fields[19]
1633
+ # self.__data = self.__data[80:]
1634
+
1635
+ command = CMD_GET_FREE_SIZES
1636
+ response_size = 1024
1637
+ cmd_response = self.send_command(command, "", response_size)
1638
+
1639
+ if cmd_response && cmd_response[:status]
1640
+ if @verbose
1641
+ puts "Data hex: #{@data.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1642
+ puts "Data Python format: #{python_format(@data)}"
1643
+ end
1644
+
1645
+ size = @data.size
1646
+ if @verbose
1647
+ puts "Data size: #{size} bytes"
1648
+ end
1649
+
1650
+ if @data.size >= 80
1651
+ # In Python: fields = unpack('20i', self.__data[:80])
1652
+ # In Ruby, 'l<' is a signed 32-bit integer (4 bytes) in little-endian format, which matches Python's 'i'
1653
+ fields = @data[0...80].unpack('l<20')
1654
+
1655
+ if @verbose
1656
+ puts "Unpacked fields: #{fields.inspect}"
1657
+ end
1658
+
1659
+ @users = fields[4]
1660
+ @fingers = fields[6]
1661
+ @records = fields[8]
1662
+ @dummy = fields[10] # ???
1663
+ @cards = fields[12]
1664
+ @fingers_cap = fields[14]
1665
+ @users_cap = fields[15]
1666
+ @rec_cap = fields[16]
1667
+ @fingers_av = fields[17]
1668
+ @users_av = fields[18]
1669
+ @rec_av = fields[19]
1670
+ @data = @data[80..-1]
1671
+
1672
+ # Check for face information (added to match Python implementation)
1673
+ if @data.size >= 12 # face info
1674
+ # In Python: fields = unpack('3i', self.__data[:12]) #dirty hack! we need more information
1675
+ face_fields = @data[0...12].unpack('l<3')
1676
+ @faces = face_fields[0]
1677
+ @faces_cap = face_fields[2]
1678
+
1679
+ if @verbose
1680
+ puts "Face info: faces=#{@faces}, capacity=#{@faces_cap}"
1681
+ end
1682
+ end
1683
+
1684
+ if @verbose
1685
+ puts "Device info: users=#{@users}, fingers=#{@fingers}, records=#{@records}"
1686
+ puts "Capacity: users=#{@users_cap}, fingers=#{@fingers_cap}, records=#{@rec_cap}"
1687
+ end
1688
+
1689
+ return true
1690
+ end
1691
+ else
1692
+ raise RBZK::ZKErrorResponse, "Can't read sizes"
1693
+ end
1694
+
1695
+ false
1696
+ end
1697
+
1698
+ def get_free_sizes
1699
+ self.send_command(CMD_GET_FREE_SIZES)
1700
+ reply = self.recv_reply
1701
+
1702
+ if reply && reply.size >= 8
1703
+ sizes_data = reply[8..-1].unpack('S<*')
1704
+
1705
+ return {
1706
+ users: sizes_data[0],
1707
+ fingers: sizes_data[2],
1708
+ capacity: sizes_data[4],
1709
+ logs: sizes_data[6],
1710
+ passwords: sizes_data[8]
1711
+ }
1712
+ end
1713
+
1714
+ nil
1715
+ end
1716
+
1717
+ def get_templates
1718
+ fingers = []
1719
+
1720
+ self.send_command(CMD_PREPARE_DATA, [ FCT_FINGERTMP ].pack('C'))
1721
+ self.recv_reply
1722
+
1723
+ data_size = self.recv_long
1724
+ templates_data = self.recv_chunk(data_size)
1725
+
1726
+ if templates_data && !templates_data.empty?
1727
+ offset = 0
1728
+ while offset < data_size
1729
+ if data_size - offset >= 608
1730
+ template_data = templates_data[offset..offset + 608]
1731
+ uid, fid, valid, template = template_data.unpack('S<S<C a*')
1732
+
1733
+ fingers << RBZK::Finger.new(uid, fid, valid, template)
1734
+ end
1735
+
1736
+ offset += 608
1737
+ end
1738
+ end
1739
+
1740
+ fingers
1741
+ end
1742
+
1743
+ def get_user_template(uid, finger_id)
1744
+ self.send_command(CMD_GET_USERTEMP, [ uid, finger_id ].pack('S<S<'))
1745
+ reply = self.recv_reply
1746
+
1747
+ if reply && reply.size >= 8
1748
+ template_data = reply[8..-1]
1749
+ valid = template_data[0].ord
1750
+ template = template_data[1..-1]
1751
+
1752
+ return RBZK::Finger.new(uid, finger_id, valid, template)
1753
+ end
1754
+
1755
+ nil
1756
+ end
1757
+
1758
+ private
1759
+
1760
+ def create_socket
1761
+ # Match Python's __create_socket method exactly
1762
+ if @verbose
1763
+ puts "Creating socket for #{@tcp ? 'TCP' : 'UDP'} connection"
1764
+ end
1765
+
1766
+ if @tcp
1767
+ # Create TCP socket (like Python's socket(AF_INET, SOCK_STREAM))
1768
+ @socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
1769
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [ @timeout, 0 ].pack('l_*'))
1770
+
1771
+ # Connect with connect_ex (like Python's connect_ex)
1772
+ begin
1773
+ # Use connect_ex like Python (returns error code instead of raising exception)
1774
+ sockaddr = Socket.pack_sockaddr_in(@port, @ip)
1775
+ @socket.connect_nonblock(sockaddr)
1776
+ if @verbose
1777
+ puts "TCP socket connected successfully"
1778
+ end
1779
+ rescue IO::WaitWritable
1780
+ # Socket is in progress of connecting
1781
+ ready = IO.select(nil, [ @socket ], nil, @timeout)
1782
+ if ready
1783
+ begin
1784
+ @socket.connect_nonblock(sockaddr)
1785
+ rescue Errno::EISCONN
1786
+ # Already connected, which is fine
1787
+ if @verbose
1788
+ puts "TCP socket connected successfully"
1789
+ end
1790
+ rescue => e
1791
+ # Connection failed
1792
+ if @verbose
1793
+ puts "TCP socket connection failed: #{e.message}"
1794
+ end
1795
+ raise e
1796
+ end
1797
+ else
1798
+ # Connection timed out
1799
+ if @verbose
1800
+ puts "TCP socket connection timed out"
1801
+ end
1802
+ raise Errno::ETIMEDOUT
1803
+ end
1804
+ rescue Errno::EISCONN
1805
+ # Already connected, which is fine
1806
+ if @verbose
1807
+ puts "TCP socket already connected"
1808
+ end
1809
+ end
1810
+ else
1811
+ # Create UDP socket (like Python's socket(AF_INET, SOCK_DGRAM))
1812
+ @socket = Socket.new(Socket::AF_INET, Socket::SOCK_DGRAM)
1813
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [ @timeout, 0 ].pack('l_*'))
1814
+
1815
+ if @verbose
1816
+ puts "UDP socket created successfully"
1817
+ end
1818
+ end
1819
+ rescue => e
1820
+ if @verbose
1821
+ puts "Socket creation failed: #{e.message}"
1822
+ end
1823
+ raise RBZK::ZKNetworkError, "Failed to create socket: #{e.message}"
1824
+ end
1825
+
1826
+ def test_tcp_top(packet)
1827
+ # If packet is nil or too small, return 0
1828
+ return 0 if packet.nil? || packet.size <= 8
1829
+
1830
+ # Ensure packet is a binary string
1831
+ # packet = packet.to_s.b
1832
+
1833
+ # Unpack the TCP header - equivalent to Python's unpack('<HHI', packet[:8])
1834
+ # S< - unsigned short (2 bytes) little-endian - matches Python's H
1835
+ # L< - unsigned long (4 bytes) little-endian - matches Python's I
1836
+ tcp_header = packet[0...8].unpack('S<S<L<')
1837
+
1838
+ # Check if the header matches the expected values
1839
+ if tcp_header[0] == MACHINE_PREPARE_DATA_1 && tcp_header[1] == MACHINE_PREPARE_DATA_2
1840
+ return tcp_header[2] # Return the size (3rd element)
1841
+ end
1842
+
1843
+ # Default return 0
1844
+ return 0
1845
+ end
1846
+
1847
+ def ping
1848
+ if @verbose
1849
+ puts "Pinging device at #{@ip}:#{@port}..."
1850
+ end
1851
+
1852
+ # Try TCP ping first
1853
+ begin
1854
+ Timeout.timeout(5) do
1855
+ s = TCPSocket.new(@ip, @port)
1856
+ s.close
1857
+ if @verbose
1858
+ puts "TCP ping successful"
1859
+ end
1860
+ return true
1861
+ end
1862
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
1863
+ if @verbose
1864
+ puts "TCP ping failed: #{e.message}"
1865
+ end
1866
+ end
1867
+
1868
+ # If TCP ping fails, try UDP ping
1869
+ begin
1870
+ udp_socket = UDPSocket.new
1871
+ udp_socket.connect(@ip, @port)
1872
+ udp_socket.send("\x00" * 8, 0)
1873
+ ready = IO.select([ udp_socket ], nil, nil, 5)
1874
+
1875
+ if ready
1876
+ if @verbose
1877
+ puts "UDP ping successful"
1878
+ end
1879
+ udp_socket.close
1880
+ return true
1881
+ else
1882
+ if @verbose
1883
+ puts "UDP ping timed out"
1884
+ end
1885
+ udp_socket.close
1886
+ return false
1887
+ end
1888
+ rescue => e
1889
+ if @verbose
1890
+ puts "UDP ping failed: #{e.message}"
1891
+ end
1892
+ return false
1893
+ end
1894
+ end
1895
+
1896
+ def calculate_checksum(buf)
1897
+ # Match Python's __create_checksum method exactly
1898
+ # In Python:
1899
+ # def __create_checksum(self, p):
1900
+ # l = len(p)
1901
+ # checksum = 0
1902
+ # while l > 1:
1903
+ # checksum += unpack('H', pack('BB', p[0], p[1]))[0]
1904
+ # p = p[2:]
1905
+ # if checksum > const.USHRT_MAX:
1906
+ # checksum -= const.USHRT_MAX
1907
+ # l -= 2
1908
+ # if l:
1909
+ # checksum = checksum + p[-1]
1910
+ # while checksum > const.USHRT_MAX:
1911
+ # checksum -= const.USHRT_MAX
1912
+ # checksum = ~checksum
1913
+ # while checksum < 0:
1914
+ # checksum += const.USHRT_MAX
1915
+ # return pack('H', checksum)
1916
+
1917
+ # Get the length of the buffer
1918
+ l = buf.size
1919
+ checksum = 0
1920
+ i = 0
1921
+
1922
+ # Process pairs of bytes
1923
+ while l > 1
1924
+ # In Python: checksum += unpack('H', pack('BB', p[0], p[1]))[0]
1925
+ # This is combining two bytes into a 16-bit value (little-endian)
1926
+ checksum += (buf[i] | (buf[i + 1] << 8))
1927
+ i += 2
1928
+
1929
+ # In Python: if checksum > const.USHRT_MAX: checksum -= const.USHRT_MAX
1930
+ # Handle overflow immediately after each addition
1931
+ if checksum > USHRT_MAX
1932
+ checksum -= USHRT_MAX
1933
+ end
1934
+
1935
+ l -= 2
1936
+ end
1937
+
1938
+ # Handle odd byte if present
1939
+ # In Python: if l: checksum = checksum + p[-1]
1940
+ if l > 0
1941
+ checksum += buf[i]
1942
+ end
1943
+
1944
+ # Handle overflow
1945
+ # In Python: while checksum > const.USHRT_MAX: checksum -= const.USHRT_MAX
1946
+ while checksum > USHRT_MAX
1947
+ checksum -= USHRT_MAX
1948
+ end
1949
+
1950
+ # Bitwise complement
1951
+ # In Python: checksum = ~checksum
1952
+ checksum = ~checksum
1953
+
1954
+ # Handle negative values
1955
+ # In Python: while checksum < 0: checksum += const.USHRT_MAX
1956
+ while checksum < 0
1957
+ checksum += USHRT_MAX
1958
+ end
1959
+
1960
+ # Return the checksum
1961
+ checksum
1962
+ end
1963
+
1964
+ # Helper method to debug binary data in both Python and Ruby formats
1965
+ def debug_binary(name, data)
1966
+ return unless @verbose
1967
+ puts "#{name} (hex): #{data.bytes.map { |b| "\\x#{b.to_s(16).rjust(2, '0')}" }.join('')}"
1968
+ puts "#{name} (Ruby): #{data.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1969
+ puts "#{name} (Python): #{format_as_python_bytes(data)}"
1970
+ end
1971
+
1972
+ def create_tcp_top(packet)
1973
+ # Match Python's __create_tcp_top method exactly
1974
+ puts "\n*** DEBUG: create_tcp_top called ***" if @verbose
1975
+ length = packet.size
1976
+ # In Python: pack('<HHI', const.MACHINE_PREPARE_DATA_1, const.MACHINE_PREPARE_DATA_2, length)
1977
+ # In Ruby: [MACHINE_PREPARE_DATA_1, MACHINE_PREPARE_DATA_2, length].pack('S<S<I<')
1978
+ top = [ MACHINE_PREPARE_DATA_1, MACHINE_PREPARE_DATA_2, length ].pack('S<S<I<')
1979
+
1980
+ if @verbose
1981
+ puts "\nTCP header components:"
1982
+ puts " MACHINE_PREPARE_DATA_1: 0x#{MACHINE_PREPARE_DATA_1.to_s(16)} (#{MACHINE_PREPARE_DATA_1}) - should be 'PP' in ASCII"
1983
+ puts " MACHINE_PREPARE_DATA_2: 0x#{MACHINE_PREPARE_DATA_2.to_s(16)} (#{MACHINE_PREPARE_DATA_2}) - should be '\\x82\\x7d' in hex"
1984
+ puts " packet length: #{length}"
1985
+
1986
+ # Show the expected Python representation
1987
+ expected_python_header = "PP\\x82\\x7d\\x#{(length & 0xFF).to_s(16).rjust(2, '0')}\\x#{((length >> 8) & 0xFF).to_s(16).rjust(2, '0')}\\x#{((length >> 16) & 0xFF).to_s(16).rjust(2, '0')}\\x#{((length >> 24) & 0xFF).to_s(16).rjust(2, '0')}"
1988
+ puts " Expected Python header: #{expected_python_header}"
1989
+
1990
+ # Show the actual bytes of the constants
1991
+ puts " MACHINE_PREPARE_DATA_1 bytes: #{[ MACHINE_PREPARE_DATA_1 ].pack('S<').bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1992
+ puts " MACHINE_PREPARE_DATA_2 bytes: #{[ MACHINE_PREPARE_DATA_2 ].pack('S<').bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
1993
+
1994
+ debug_binary("TCP header only", top) # This is just the 8-byte header
1995
+ debug_binary("Full TCP packet (what Python calls 'top')", top + packet) # This is what we return
1996
+ end
1997
+
1998
+ # Print debug info right before returning
1999
+ if @verbose
2000
+ puts "\n*** FINAL TCP PACKET DEBUG ***"
2001
+ puts "In both Python and Ruby, the variable 'top' is just the TCP header, but the method returns 'top + packet':"
2002
+ puts "TCP header format: b'PP\\x82\\x7d\\x#{(length & 0xFF).to_s(16).rjust(2, '0')}\\x#{((length >> 8) & 0xFF).to_s(16).rjust(2, '0')}\\x#{((length >> 16) & 0xFF).to_s(16).rjust(2, '0')}\\x#{((length >> 24) & 0xFF).to_s(16).rjust(2, '0')}'"
2003
+ puts "Return value format (top + packet): TCP header + command packet"
2004
+ puts "Ruby 'top + packet' format: #{(top + packet).bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
2005
+ puts "Hex format: #{(top + packet).bytes.map { |b| "\\x#{b.to_s(16).rjust(2, '0')}" }.join('')}"
2006
+ end
2007
+
2008
+ # Return top + packet (like Python's return top + packet)
2009
+ result = top + packet
2010
+
2011
+ if @verbose
2012
+ puts "\n*** SUPER EXPLICIT DEBUG ***"
2013
+ puts "top bytes: #{top.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
2014
+ puts "packet bytes: #{packet.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
2015
+ puts "result bytes: #{result.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
2016
+ puts "result size: #{result.size} bytes"
2017
+ end
2018
+
2019
+ result
2020
+ end
2021
+
2022
+ def create_header(command, command_string = "".b, session_id = 0, reply_id = 0)
2023
+ # Match Python's __create_header method exactly
2024
+ # In Python:
2025
+ # def __create_header(self, command, command_string, session_id, reply_id):
2026
+ # buf = pack('<4H', command, 0, session_id, reply_id) + command_string
2027
+ # buf = unpack('8B' + '%sB' % len(command_string), buf)
2028
+ # checksum = unpack('H', self.__create_checksum(buf))[0]
2029
+ # reply_id += 1
2030
+ # if reply_id >= const.USHRT_MAX:
2031
+ # reply_id -= const.USHRT_MAX
2032
+ # buf = pack('<4H', command, checksum, session_id, reply_id)
2033
+ # return buf + command_string
2034
+
2035
+ # Ensure command_string is a binary string
2036
+ command_string = command_string.to_s.b
2037
+
2038
+ # Step 1: Create initial header and combine with command_string
2039
+ # In Python: buf = pack('<4H', command, 0, session_id, reply_id) + command_string
2040
+ buf = [ command, 0, session_id, reply_id ].pack('v4') + command_string
2041
+
2042
+ # Step 2: Convert to bytes array for checksum calculation
2043
+ # In Python: buf = unpack('8B' + '%sB' % len(command_string), buf)
2044
+ # This unpacks the buffer into individual bytes
2045
+ # In Ruby, we can use String#bytes to get an array of bytes
2046
+ buf = buf.unpack("C#{8 + command_string.length}")
2047
+
2048
+ # Step 3: Calculate checksum
2049
+ # In Python: checksum = unpack('H', self.__create_checksum(buf))[0]
2050
+ checksum = calculate_checksum(buf)
2051
+
2052
+ # Step 4: Update reply_id
2053
+ # In Python: reply_id += 1; if reply_id >= const.USHRT_MAX: reply_id -= const.USHRT_MAX
2054
+ reply_id += 1
2055
+ if reply_id >= USHRT_MAX
2056
+ reply_id -= USHRT_MAX
2057
+ end
2058
+
2059
+ # Step 5: Create final header with updated values
2060
+ # In Python: buf = pack('<4H', command, checksum, session_id, reply_id)
2061
+ buf = [ command, checksum, session_id, reply_id ].pack('v4')
2062
+
2063
+ if @verbose
2064
+ puts "Header components:"
2065
+ puts " Command: #{command}"
2066
+ puts " Checksum: #{checksum}"
2067
+ puts " Session ID: #{session_id}"
2068
+ puts " Reply ID: #{reply_id}"
2069
+
2070
+ if !command_string.empty?
2071
+ debug_binary("Command string", command_string)
2072
+ else
2073
+ puts "Command string: (empty)"
2074
+ end
2075
+ debug_binary("Final header", buf)
2076
+ end
2077
+
2078
+ buf + command_string
2079
+ end
2080
+
2081
+ def send_command(command, command_string = "".b, response_size = 8)
2082
+ # Match Python's __send_command method exactly
2083
+ # In Python:
2084
+ # def __send_command(self, command, command_string=b'', response_size=8):
2085
+ # if command not in [const.CMD_CONNECT, const.CMD_AUTH] and not self.is_connect:
2086
+ # raise ZKErrorConnection("instance are not connected.")
2087
+ # buf = self.__create_header(command, command_string, self.__session_id, self.__reply_id)
2088
+ # try:
2089
+ # if self.tcp:
2090
+ # top = self.__create_tcp_top(buf)
2091
+ # self.__sock.send(top)
2092
+ # self.__tcp_data_recv = self.__sock.recv(response_size + 8)
2093
+ # self.__tcp_length = self.__test_tcp_top(self.__tcp_data_recv)
2094
+ # if self.__tcp_length == 0:
2095
+ # raise ZKNetworkError("TCP packet invalid")
2096
+ # self.__header = unpack('<4H', self.__tcp_data_recv[8:16])
2097
+ # self.__data_recv = self.__tcp_data_recv[8:]
2098
+ # else:
2099
+ # self.__sock.sendto(buf, self.__address)
2100
+ # self.__data_recv = self.__sock.recv(response_size)
2101
+ # self.__header = unpack('<4H', self.__data_recv[:8])
2102
+ # except Exception as e:
2103
+ # raise ZKNetworkError(str(e))
2104
+ # self.__response = self.__header[0]
2105
+ # self.__reply_id = self.__header[3]
2106
+ # self.__data = self.__data_recv[8:]
2107
+ # if self.__response in [const.CMD_ACK_OK, const.CMD_PREPARE_DATA, const.CMD_DATA]:
2108
+ # return {
2109
+ # 'status': True,
2110
+ # 'code': self.__response
2111
+ # }
2112
+ # return {
2113
+ # 'status': False,
2114
+ # 'code': self.__response
2115
+ # }
2116
+
2117
+ # Check connection status (except for connect and auth commands)
2118
+ if command != CMD_CONNECT && command != CMD_AUTH && !@connected
2119
+ raise RBZK::ZKErrorConnection, "Instance are not connected."
2120
+ end
2121
+
2122
+ # In Python, command_string is a bytes object (b'')
2123
+ # In Ruby, we use binary strings (ASCII-8BIT encoding)
2124
+ command_string = command_string.to_s.b
2125
+
2126
+ if @verbose
2127
+ puts "command_string class: #{command_string.class}"
2128
+ puts "command_string encoding: #{command_string.encoding}"
2129
+ puts "command_string bytes: #{python_format(command_string)}"
2130
+ end
2131
+
2132
+ # Create command header (like Python's __create_header)
2133
+ buf = create_header(command, command_string, @session_id, @reply_id)
2134
+
2135
+ if @verbose
2136
+ puts "\nSending command #{command} with session id #{@session_id} and reply id #{@reply_id}"
2137
+ puts "buf: #{python_format(buf)}"
2138
+ end
2139
+
2140
+ begin
2141
+ puts "\n*** DEBUG: Using #{@tcp ? 'TCP' : 'UDP'} mode ***" if @verbose
2142
+ if @tcp
2143
+ # Create TCP header (like Python's __create_tcp_top)
2144
+ puts "\n*** Before create_tcp_top ***" if @verbose
2145
+ puts "buf size: #{buf.size} bytes" if @verbose
2146
+ top = create_tcp_top(buf)
2147
+ puts "\n*** After create_tcp_top ***" if @verbose
2148
+ puts "top size: #{top.size} bytes" if @verbose
2149
+
2150
+ if @verbose
2151
+ puts "\nSending TCP packet:"
2152
+ puts "Note: In send_command, 'top' variable contains the full packet (header + command packet)"
2153
+ puts "This is because create_tcp_top returns the full packet, not just the header"
2154
+ debug_binary("Command packet (buf)", buf)
2155
+ debug_binary("Full TCP packet (top)", top) # 'top' contains the full packet here
2156
+ end
2157
+
2158
+ @socket.send(top, 0)
2159
+ @tcp_data_recv = @socket.recv(response_size + 8)
2160
+ @tcp_length = test_tcp_top(@tcp_data_recv)
2161
+
2162
+ if @verbose
2163
+ puts "\nReceived TCP response:"
2164
+ debug_binary("TCP response", @tcp_data_recv)
2165
+ end
2166
+
2167
+ if @tcp_length == 0
2168
+ raise RBZK::ZKNetworkError, "TCP packet invalid"
2169
+ end
2170
+
2171
+ @header = @tcp_data_recv[8..15].unpack('v4')
2172
+ @data_recv = @tcp_data_recv[8..-1]
2173
+ else
2174
+ # Send UDP packet
2175
+ @socket.send(buf, 0, @ip, @port)
2176
+ @data_recv = @socket.recv(response_size)
2177
+ @header = @data_recv[0..7].unpack('S<4')
2178
+ end
2179
+ rescue => e
2180
+ if @verbose
2181
+ puts "Connection error during send: #{e.message}"
2182
+ end
2183
+ raise RBZK::ZKNetworkError, e.message
2184
+ end
2185
+
2186
+ # Process response (like Python's __send_command)
2187
+ @response = @header[0]
2188
+ @reply_id = @header[3]
2189
+ @data = @data_recv[8..-1] # This is the key line that matches Python's self.__data = self.__data_recv[8:]
2190
+
2191
+ # Return response status (like Python's __send_command)
2192
+ if @response == CMD_ACK_OK || @response == CMD_PREPARE_DATA || @response == CMD_DATA
2193
+ return {
2194
+ status: true,
2195
+ code: @response
2196
+ }
2197
+ else
2198
+ return {
2199
+ status: false,
2200
+ code: @response
2201
+ }
2202
+ end
2203
+ end
2204
+
2205
+ def recv_reply
2206
+ begin
2207
+ if @verbose
2208
+ puts "Waiting for TCP reply"
2209
+ end
2210
+
2211
+ # Set a timeout for the read operation
2212
+ Timeout.timeout(5) do
2213
+ # Read TCP header (8 bytes)
2214
+ tcp_header = @socket.read(8)
2215
+ return nil unless tcp_header && tcp_header.size >= 8
2216
+
2217
+ # Parse TCP header
2218
+ tcp_format1, tcp_format2, tcp_length = tcp_header.unpack('S<S<I<')
2219
+
2220
+ if @verbose
2221
+ puts "TCP header: format1=#{tcp_format1}, format2=#{tcp_format2}, length=#{tcp_length}"
2222
+ end
2223
+
2224
+ # Verify TCP header format
2225
+ if tcp_format1 != MACHINE_PREPARE_DATA_1 || tcp_format2 != MACHINE_PREPARE_DATA_2
2226
+ if @verbose
2227
+ puts "Invalid TCP header format: #{tcp_format1}, #{tcp_format2}"
2228
+ end
2229
+ return nil
2230
+ end
2231
+
2232
+ # Read command header (8 bytes)
2233
+ cmd_header = @socket.read(8)
2234
+ return nil unless cmd_header && cmd_header.size >= 8
2235
+
2236
+ # Parse command header
2237
+ command, checksum, session_id, reply_id = cmd_header.unpack('S<4')
2238
+
2239
+ if @verbose
2240
+ puts "Command header: cmd=#{command}, checksum=#{checksum}, session=#{session_id}, reply=#{reply_id}"
2241
+ end
2242
+
2243
+ # Calculate data size (TCP length - 8 bytes for command header)
2244
+ data_size = tcp_length - 8
2245
+
2246
+ # Read data if available
2247
+ data = ""
2248
+ if data_size > 0
2249
+ if @verbose
2250
+ puts "Reading #{data_size} bytes of data"
2251
+ end
2252
+
2253
+ # Read data in chunks to handle large responses (like Python implementation)
2254
+ remaining = data_size
2255
+ while remaining > 0
2256
+ chunk_size = [ remaining, 4096 ].min
2257
+ chunk = @socket.read(chunk_size)
2258
+ if chunk.nil? || chunk.empty?
2259
+ if @verbose
2260
+ puts "Failed to read data chunk, got #{chunk.inspect}"
2261
+ end
2262
+ break
2263
+ end
2264
+
2265
+ data += chunk
2266
+ remaining -= chunk.size
2267
+
2268
+ if @verbose && remaining > 0
2269
+ puts "Read #{chunk.size} bytes, #{remaining} remaining"
2270
+ end
2271
+ end
2272
+ end
2273
+
2274
+ # Store data for later use
2275
+ @data_recv = data
2276
+
2277
+ # Update session ID
2278
+ @session_id = session_id
2279
+
2280
+ # Check command type and handle accordingly (like Python implementation)
2281
+ if command == CMD_ACK_OK
2282
+ if @verbose
2283
+ puts "Received ACK_OK"
2284
+ end
2285
+ return cmd_header + data
2286
+ elsif command == CMD_ACK_ERROR
2287
+ if @verbose
2288
+ puts "Received ACK_ERROR"
2289
+ end
2290
+ return nil
2291
+ elsif command == CMD_ACK_DATA
2292
+ if @verbose
2293
+ puts "Received ACK_DATA"
2294
+ end
2295
+ if data_size > 0
2296
+ return cmd_header + data
2297
+ else
2298
+ return nil
2299
+ end
2300
+ else
2301
+ if @verbose
2302
+ puts "Received unknown command: #{command}"
2303
+ end
2304
+ return cmd_header + data
2305
+ end
2306
+ end
2307
+ rescue Timeout::Error => e
2308
+ if @verbose
2309
+ puts "Timeout waiting for response: #{e.message}"
2310
+ end
2311
+ raise RBZK::ZKErrorResponse, "Timeout waiting for response"
2312
+ rescue Errno::ECONNRESET, Errno::EPIPE => e
2313
+ if @verbose
2314
+ puts "Connection error during receive: #{e.message}"
2315
+ end
2316
+ raise RBZK::ZKNetworkError, "Connection error: #{e.message}"
2317
+ rescue Errno::EAGAIN, Errno::EWOULDBLOCK => e
2318
+ if @verbose
2319
+ puts "Timeout waiting for response: #{e.message}"
2320
+ end
2321
+ raise RBZK::ZKErrorResponse, "Timeout waiting for response"
2322
+ end
2323
+
2324
+ nil
2325
+ end
2326
+
2327
+ def recv_long
2328
+ if @data_recv && @data_recv.size >= 4
2329
+ data = @data_recv[0..3]
2330
+ @data_recv = @data_recv[4..-1]
2331
+ return data.unpack('L<')[0]
2332
+ end
2333
+
2334
+ 0
2335
+ end
2336
+
2337
+ def recv_chunk(size)
2338
+ if @verbose
2339
+ puts "Receiving chunk of #{size} bytes"
2340
+ end
2341
+
2342
+ if @data_recv && @data_recv.size >= size
2343
+ data = @data_recv[0...size]
2344
+ @data_recv = @data_recv[size..-1]
2345
+
2346
+ if @verbose
2347
+ puts "Received #{data.size} bytes from buffer"
2348
+ end
2349
+
2350
+ return data
2351
+ end
2352
+
2353
+ if @verbose
2354
+ puts "Warning: No data available in buffer"
2355
+ end
2356
+
2357
+ # Return empty string if no data is available
2358
+ ''
2359
+ end
2360
+
2361
+ def make_commkey(key, session_id, ticks = 50)
2362
+ if @verbose
2363
+ puts "\n*** DEBUG: make_commkey called ***"
2364
+ puts "key: #{key}, session_id: #{session_id}, ticks: #{ticks}"
2365
+ end
2366
+
2367
+ key = key.to_i
2368
+ session_id = session_id.to_i
2369
+ k = 0
2370
+
2371
+ 32.times do |i|
2372
+ if (key & (1 << i)) != 0
2373
+ k = (k << 1 | 1)
2374
+ else
2375
+ k = k << 1
2376
+ end
2377
+ end
2378
+
2379
+ k += session_id
2380
+
2381
+ # Pack the integer into 4 bytes and unpack as individual bytes
2382
+ k_bytes = [ k ].pack('L<').unpack('C4')
2383
+
2384
+ # XOR with 'ZKSO'
2385
+ k_bytes = [
2386
+ k_bytes[0] ^ 'Z'.ord,
2387
+ k_bytes[1] ^ 'K'.ord,
2388
+ k_bytes[2] ^ 'S'.ord,
2389
+ k_bytes[3] ^ 'O'.ord
2390
+ ]
2391
+
2392
+ # Pack the bytes back into a string and unpack as 2 shorts
2393
+ k_shorts = k_bytes.pack('C4').unpack('S<2')
2394
+
2395
+ # Swap the shorts
2396
+ k_shorts = [ k_shorts[1], k_shorts[0] ]
2397
+
2398
+ # Get the low byte of ticks
2399
+ b = 0xff & ticks
2400
+
2401
+ # Pack the shorts back into a string and unpack as 4 bytes
2402
+ k_bytes = k_shorts.pack('S<2').unpack('C4')
2403
+
2404
+ # XOR with ticks and pack back into a string
2405
+ # In Python: pack(b'BBBB', k[0] ^ B, k[1] ^ B, B, k[3] ^ B)
2406
+ # Note: The third byte is just B, not k[2] ^ B
2407
+ result = [ k_bytes[0] ^ b, k_bytes[1] ^ b, b, k_bytes[3] ^ b ].pack('C4')
2408
+
2409
+ if @verbose
2410
+ puts "Final commkey bytes: #{result.bytes.map { |b| "0x#{b.to_s(16).rjust(2, '0')}" }.join(' ')}"
2411
+ end
2412
+
2413
+ result
2414
+ end
2415
+ end
2416
+ end