rtlsdr 0.2.4 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b12c3758778bb23282dd45b6fd7a7875ab966902921da11a01e20e091e695f3
4
- data.tar.gz: 9d89756d0d0777d2f60e650809c38c5aad56a33e115e873e6557bb5586720b02
3
+ metadata.gz: 476e8a7b2e6fbc8f34a9522b3bcd203033fa8aa031c87ff9f6a8d65c447520c1
4
+ data.tar.gz: 97ccaa1da588545b2a3e4e739825757cde0364775b660a1bc644034c94bc4a7d
5
5
  SHA512:
6
- metadata.gz: 57cfd89abe99c55d86e66e3fee3b0da12da94f6feceab7860be79871cd9611bf31ce3f9a2fb8235f1cb2f5ae06bb353df56c3bae3df5c2b4f3affc0267d3bce9
7
- data.tar.gz: 1f9c2f119f049eaa84aff2c4afbef95854ebcd3a241441d0676a0f9220b064730b91ee6e95b7d6afbbcdfc40e673418de1f8a70ae1ffea041a65bde26b7c047c
6
+ metadata.gz: 2b8dd3cf3f66bce9c6d76dfb5d6fe83285bf5febcd4a84d673340dbd9d1402f87d30de4f352f514391a86707a468d3e191f33a7cd4e88c06a9cbe0bb7e4292de
7
+ data.tar.gz: d45d8464b357cadea1a6125aaa23e5ef710ce2ea45823a22af014b28d5541fe3162d42e4b66de7f4e7d70974bd7e894c3678dd0f4de3de49268e56195f2cfa11
data/.rubocop.yml CHANGED
@@ -18,10 +18,22 @@ Metrics/ClassLength:
18
18
  Max: 500
19
19
  Metrics/MethodLength:
20
20
  Max: 20
21
+ Exclude:
22
+ - 'lib/rtlsdr/fftw.rb'
23
+ - 'lib/rtlsdr/demod.rb'
21
24
  Metrics/BlockLength:
22
25
  Max: 20
23
26
  Metrics/AbcSize:
24
27
  Max: 25
28
+ Exclude:
29
+ - 'lib/rtlsdr/fftw.rb'
30
+ - 'lib/rtlsdr/demod.rb'
31
+ - 'lib/rtlsdr/dsp.rb'
32
+ - 'lib/rtlsdr/dsp/filter.rb'
33
+ Metrics/ModuleLength:
34
+ Exclude:
35
+ - 'lib/rtlsdr/demod.rb'
36
+ - 'lib/rtlsdr/dsp.rb'
25
37
  Metrics/PerceivedComplexity:
26
38
  Max: 20
27
39
  Metrics/CyclomaticComplexity:
@@ -32,3 +44,16 @@ Naming/MethodParameterName:
32
44
 
33
45
  RSpec/MultipleExpectations:
34
46
  Enabled: false
47
+ RSpec/ExampleLength:
48
+ Enabled: false
49
+ RSpec/MessageSpies:
50
+ Enabled: false
51
+ RSpec/StubbedMock:
52
+ Enabled: false
53
+ RSpec/MultipleDescribes:
54
+ Enabled: false
55
+ RSpec/MultipleMemoizedHelpers:
56
+ Enabled: false
57
+
58
+ Style/ExponentialNotation:
59
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-01-06
6
+
7
+ ### Added
8
+
9
+ - `RTLSDR::TcpClient` - Connect to remote RTL-SDR devices via rtl_tcp
10
+ - `RTLSDR.connect(host, port)` - Factory method for TCP connections
11
+ - `RTLSDR::ConnectionError` - Exception for network connection failures
12
+ - Full API compatibility between `Device` and `TcpClient`
13
+
14
+ ### Changed
15
+
16
+ - Version bump to 0.3.0 (new feature)
17
+
5
18
  ## [0.1.13] - 2025-09-26
6
19
 
7
20
  ### Added
data/lib/rtlsdr/errors.rb CHANGED
@@ -69,4 +69,12 @@ module RTLSDR
69
69
  #
70
70
  # @since 0.1.0
71
71
  class CallbackError < Error; end
72
+
73
+ # Raised when network connection fails
74
+ #
75
+ # This exception is thrown when connecting to an rtl_tcp server fails,
76
+ # or when the connection is lost during operation.
77
+ #
78
+ # @since 0.3.0
79
+ class ConnectionError < Error; end
72
80
  end
data/lib/rtlsdr/ffi.rb CHANGED
@@ -144,7 +144,8 @@ module RTLSDR
144
144
  attach_function :rtlsdr_reset_buffer, [:rtlsdr_dev_t], :int
145
145
  attach_function :rtlsdr_read_sync, %i[rtlsdr_dev_t pointer int pointer], :int, blocking: true
146
146
  attach_function :rtlsdr_wait_async, %i[rtlsdr_dev_t rtlsdr_read_async_cb_t pointer], :int, blocking: true
147
- attach_function :rtlsdr_read_async, %i[rtlsdr_dev_t rtlsdr_read_async_cb_t pointer uint32 uint32], :int, blocking: true
147
+ attach_function :rtlsdr_read_async, %i[rtlsdr_dev_t rtlsdr_read_async_cb_t pointer uint32 uint32], :int,
148
+ blocking: true
148
149
  attach_function :rtlsdr_cancel_async, [:rtlsdr_dev_t], :int
149
150
 
150
151
  # Bias tee functions
@@ -0,0 +1,629 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module RTLSDR
6
+ # TCP client for connecting to rtl_tcp servers
7
+ #
8
+ # TcpClient provides a network interface to RTL-SDR devices that are shared
9
+ # over the network using the rtl_tcp utility. It implements the same interface
10
+ # as RTLSDR::Device, allowing seamless switching between local and remote
11
+ # devices.
12
+ #
13
+ # rtl_tcp is a utility that makes RTL-SDR devices available over TCP/IP.
14
+ # Run it on a remote machine with: rtl_tcp -a 0.0.0.0
15
+ #
16
+ # @example Connect to remote device
17
+ # device = RTLSDR.connect("192.168.1.100", 1234)
18
+ # device.sample_rate = 2_048_000
19
+ # device.center_freq = 100_000_000
20
+ # device.gain = 400
21
+ # samples = device.read_samples(1024)
22
+ # device.close
23
+ #
24
+ # @example Using configure method
25
+ # device = RTLSDR.connect("localhost")
26
+ # device.configure(
27
+ # frequency: 100_000_000,
28
+ # sample_rate: 2_048_000,
29
+ # gain: 400
30
+ # )
31
+ #
32
+ # @since 0.3.0
33
+ class TcpClient
34
+ include Enumerable
35
+
36
+ # rtl_tcp command codes
37
+ CMD_SET_FREQUENCY = 0x01
38
+ CMD_SET_SAMPLE_RATE = 0x02
39
+ CMD_SET_GAIN_MODE = 0x03
40
+ CMD_SET_GAIN = 0x04
41
+ CMD_SET_FREQ_CORRECTION = 0x05
42
+ CMD_SET_IF_GAIN = 0x06
43
+ CMD_SET_TEST_MODE = 0x07
44
+ CMD_SET_AGC_MODE = 0x08
45
+ CMD_SET_DIRECT_SAMPLING = 0x09
46
+ CMD_SET_OFFSET_TUNING = 0x0a
47
+ CMD_SET_RTL_XTAL = 0x0b
48
+ CMD_SET_TUNER_XTAL = 0x0c
49
+ CMD_SET_GAIN_BY_INDEX = 0x0d
50
+ CMD_SET_BIAS_TEE = 0x0e
51
+
52
+ # Default rtl_tcp port
53
+ DEFAULT_PORT = 1234
54
+
55
+ # @return [String] Remote host address
56
+ attr_reader :host
57
+ # @return [Integer] Remote port number
58
+ attr_reader :port
59
+ # @return [Integer] Tuner type from server
60
+ attr_reader :tuner_type
61
+ # @return [Integer] Number of gain values supported
62
+ attr_reader :gain_count
63
+
64
+ # Create a new TCP client connection to an rtl_tcp server
65
+ #
66
+ # @param host [String] Hostname or IP address of rtl_tcp server
67
+ # @param port [Integer] Port number (default: 1234)
68
+ # @param timeout [Integer] Connection timeout in seconds (default: 10)
69
+ # @raise [ConnectionError] if connection fails
70
+ # @raise [ConnectionError] if server sends invalid header
71
+ def initialize(host, port = DEFAULT_PORT, timeout: 10)
72
+ @host = host
73
+ @port = port
74
+ @timeout = timeout
75
+ @socket = nil
76
+ @streaming = false
77
+ @mutex = Mutex.new
78
+
79
+ # Cached configuration values (rtl_tcp doesn't support reading back)
80
+ @center_freq = 0
81
+ @sample_rate = 0
82
+ @tuner_gain = 0
83
+ @freq_correction = 0
84
+ @direct_sampling = 0
85
+ @offset_tuning = false
86
+ @agc_mode = false
87
+ @test_mode = false
88
+ @gain_mode_manual = false
89
+
90
+ connect_to_server
91
+ end
92
+
93
+ # Check if connected to server
94
+ #
95
+ # @return [Boolean] true if connected
96
+ def open?
97
+ !@socket.nil? && !@socket.closed?
98
+ end
99
+
100
+ # Close the connection
101
+ #
102
+ # @return [void]
103
+ def close
104
+ return unless open?
105
+
106
+ @streaming = false
107
+ @socket.close
108
+ @socket = nil
109
+ end
110
+
111
+ # Check if connection is closed
112
+ #
113
+ # @return [Boolean] true if closed
114
+ def closed?
115
+ !open?
116
+ end
117
+
118
+ # Device information
119
+ def name
120
+ "rtl_tcp://#{@host}:#{@port}"
121
+ end
122
+
123
+ # Get USB device strings (not available via TCP)
124
+ #
125
+ # @return [Hash] Hash with placeholder values
126
+ def usb_strings
127
+ {
128
+ manufacturer: "rtl_tcp",
129
+ product: "Remote RTL-SDR",
130
+ serial: "#{@host}:#{@port}"
131
+ }
132
+ end
133
+
134
+ # Get human-readable tuner name
135
+ #
136
+ # @return [String] Tuner name based on type code
137
+ def tuner_name
138
+ case @tuner_type
139
+ when 1 then "Elonics E4000"
140
+ when 2 then "Fitipower FC0012"
141
+ when 3 then "Fitipower FC0013"
142
+ when 4 then "FCI FC2580"
143
+ when 5 then "Rafael Micro R820T"
144
+ when 6 then "Rafael Micro R828D"
145
+ else "Unknown"
146
+ end
147
+ end
148
+
149
+ # Frequency control
150
+
151
+ # Set center frequency
152
+ #
153
+ # @param freq [Integer] Frequency in Hz
154
+ def center_freq=(freq)
155
+ send_command(CMD_SET_FREQUENCY, freq)
156
+ @center_freq = freq
157
+ end
158
+
159
+ # Get center frequency
160
+ #
161
+ # @return [Integer] Last set frequency in Hz
162
+ attr_reader :center_freq
163
+
164
+ alias frequency center_freq
165
+ alias frequency= center_freq=
166
+
167
+ # Set frequency correction in PPM
168
+ #
169
+ # @param ppm [Integer] Frequency correction in parts per million
170
+ def freq_correction=(ppm)
171
+ send_command(CMD_SET_FREQ_CORRECTION, ppm)
172
+ @freq_correction = ppm
173
+ end
174
+
175
+ # Get frequency correction
176
+ #
177
+ # @return [Integer] Last set frequency correction in PPM
178
+ attr_reader :freq_correction
179
+
180
+ # Crystal oscillator frequencies (not fully supported via TCP)
181
+ def set_xtal_freq(rtl_freq, tuner_freq)
182
+ send_command(CMD_SET_RTL_XTAL, rtl_freq)
183
+ send_command(CMD_SET_TUNER_XTAL, tuner_freq)
184
+ [rtl_freq, tuner_freq]
185
+ end
186
+
187
+ # Get crystal frequencies (returns zeros - not readable via TCP)
188
+ #
189
+ # @return [Array<Integer>] Array of [0, 0]
190
+ def xtal_freq
191
+ [0, 0]
192
+ end
193
+
194
+ # Gain control
195
+
196
+ # Get available tuner gains (not queryable via TCP, returns common values)
197
+ #
198
+ # @return [Array<Integer>] Common R820T gain values in tenths of dB
199
+ def tuner_gains
200
+ # Return common R820T gains as a reasonable default
201
+ [0, 9, 14, 27, 37, 77, 87, 125, 144, 157, 166, 197, 207, 229, 254,
202
+ 280, 297, 328, 338, 364, 372, 386, 402, 421, 434, 439, 445, 480, 496]
203
+ end
204
+
205
+ # Set tuner gain in tenths of dB
206
+ #
207
+ # @param gain [Integer] Gain in tenths of dB (e.g., 496 = 49.6 dB)
208
+ def tuner_gain=(gain)
209
+ send_command(CMD_SET_GAIN, gain)
210
+ @tuner_gain = gain
211
+ end
212
+
213
+ # Get current tuner gain
214
+ #
215
+ # @return [Integer] Last set gain in tenths of dB
216
+ attr_reader :tuner_gain
217
+
218
+ alias gain tuner_gain
219
+ alias gain= tuner_gain=
220
+
221
+ # Set gain mode (manual or automatic)
222
+ #
223
+ # @param manual [Boolean] true for manual, false for automatic
224
+ def tuner_gain_mode=(manual)
225
+ mode = manual ? 1 : 0
226
+ send_command(CMD_SET_GAIN_MODE, mode)
227
+ @gain_mode_manual = manual
228
+ end
229
+
230
+ # Enable manual gain mode
231
+ #
232
+ # @return [Boolean] true
233
+ def manual_gain_mode!
234
+ self.tuner_gain_mode = true
235
+ end
236
+
237
+ # Enable automatic gain mode
238
+ #
239
+ # @return [Boolean] false
240
+ def auto_gain_mode!
241
+ self.tuner_gain_mode = false
242
+ end
243
+
244
+ # Set IF gain for specific stage
245
+ #
246
+ # @param stage [Integer] IF stage number
247
+ # @param gain [Integer] Gain value in tenths of dB
248
+ # @return [Integer] The gain value that was set
249
+ def set_tuner_if_gain(stage, gain)
250
+ param = (stage << 16) | (gain & 0xFFFF)
251
+ send_command(CMD_SET_IF_GAIN, param)
252
+ gain
253
+ end
254
+
255
+ # Set tuner bandwidth (not supported via rtl_tcp)
256
+ #
257
+ # @param _bw [Integer] Bandwidth in Hz (ignored)
258
+ def tuner_bandwidth=(_bw)
259
+ # rtl_tcp doesn't support bandwidth command
260
+ end
261
+
262
+ # Sample rate control
263
+
264
+ # Set sample rate
265
+ #
266
+ # @param rate [Integer] Sample rate in Hz
267
+ def sample_rate=(rate)
268
+ send_command(CMD_SET_SAMPLE_RATE, rate)
269
+ @sample_rate = rate
270
+ end
271
+
272
+ # Get current sample rate
273
+ #
274
+ # @return [Integer] Last set sample rate in Hz
275
+ attr_reader :sample_rate
276
+
277
+ alias samp_rate sample_rate
278
+ alias samp_rate= sample_rate=
279
+
280
+ # Mode control
281
+
282
+ # Set test mode
283
+ #
284
+ # @param enabled [Boolean] true to enable test mode
285
+ def test_mode=(enabled)
286
+ mode = enabled ? 1 : 0
287
+ send_command(CMD_SET_TEST_MODE, mode)
288
+ @test_mode = enabled
289
+ end
290
+
291
+ # Enable test mode
292
+ #
293
+ # @return [Boolean] true
294
+ def test_mode!
295
+ self.test_mode = true
296
+ end
297
+
298
+ # Set AGC mode
299
+ #
300
+ # @param enabled [Boolean] true to enable AGC
301
+ def agc_mode=(enabled)
302
+ mode = enabled ? 1 : 0
303
+ send_command(CMD_SET_AGC_MODE, mode)
304
+ @agc_mode = enabled
305
+ end
306
+
307
+ # Enable AGC mode
308
+ #
309
+ # @return [Boolean] true
310
+ def agc_mode!
311
+ self.agc_mode = true
312
+ end
313
+
314
+ # Set direct sampling mode
315
+ #
316
+ # @param mode [Integer] Direct sampling mode (0=off, 1=I-ADC, 2=Q-ADC)
317
+ def direct_sampling=(mode)
318
+ send_command(CMD_SET_DIRECT_SAMPLING, mode)
319
+ @direct_sampling = mode
320
+ end
321
+
322
+ # Get direct sampling mode
323
+ #
324
+ # @return [Integer] Last set direct sampling mode
325
+ attr_reader :direct_sampling
326
+
327
+ # Set offset tuning mode
328
+ #
329
+ # @param enabled [Boolean] true to enable offset tuning
330
+ def offset_tuning=(enabled)
331
+ mode = enabled ? 1 : 0
332
+ send_command(CMD_SET_OFFSET_TUNING, mode)
333
+ @offset_tuning = enabled
334
+ end
335
+
336
+ # Get offset tuning mode
337
+ #
338
+ # @return [Boolean] Last set offset tuning state
339
+ attr_reader :offset_tuning
340
+
341
+ # Enable offset tuning
342
+ #
343
+ # @return [Boolean] true
344
+ def offset_tuning!
345
+ self.offset_tuning = true
346
+ end
347
+
348
+ # Bias tee control
349
+
350
+ # Set bias tee state
351
+ #
352
+ # @param enabled [Boolean] true to enable bias tee
353
+ def bias_tee=(enabled)
354
+ mode = enabled ? 1 : 0
355
+ send_command(CMD_SET_BIAS_TEE, mode)
356
+ end
357
+
358
+ # Enable bias tee
359
+ #
360
+ # @return [Boolean] true
361
+ def enable_bias_tee
362
+ self.bias_tee = true
363
+ end
364
+
365
+ # Disable bias tee
366
+ #
367
+ # @return [Boolean] false
368
+ def disable_bias_tee
369
+ self.bias_tee = false
370
+ end
371
+
372
+ # Enable bias tee (alias)
373
+ #
374
+ # @return [Boolean] true
375
+ def bias_tee!
376
+ enable_bias_tee
377
+ end
378
+
379
+ # Bias tee GPIO not supported via TCP
380
+ def set_bias_tee_gpio(_gpio, enabled)
381
+ self.bias_tee = enabled
382
+ end
383
+
384
+ # EEPROM not accessible via TCP
385
+
386
+ # Read EEPROM (not supported via TCP)
387
+ #
388
+ # @raise [OperationFailedError] always
389
+ def read_eeprom(_offset, _length)
390
+ raise OperationFailedError, "EEPROM access not supported via TCP"
391
+ end
392
+
393
+ # Write EEPROM (not supported via TCP)
394
+ #
395
+ # @raise [OperationFailedError] always
396
+ def write_eeprom(_data, _offset)
397
+ raise OperationFailedError, "EEPROM access not supported via TCP"
398
+ end
399
+
400
+ # Dump EEPROM (not supported via TCP)
401
+ #
402
+ # @raise [OperationFailedError] always
403
+ def dump_eeprom
404
+ raise OperationFailedError, "EEPROM access not supported via TCP"
405
+ end
406
+
407
+ # Buffer reset (no-op for TCP)
408
+ def reset_buffer
409
+ # rtl_tcp doesn't have a reset buffer command
410
+ # Data is continuously streamed
411
+ end
412
+
413
+ # Read raw IQ data synchronously
414
+ #
415
+ # @param length [Integer] Number of bytes to read
416
+ # @return [Array<Integer>] Array of 8-bit unsigned integers
417
+ # @raise [ConnectionError] if read fails
418
+ def read_sync(length)
419
+ raise ConnectionError, "Not connected" unless open?
420
+
421
+ data = +""
422
+ remaining = length
423
+
424
+ while remaining.positive?
425
+ chunk = @socket.read(remaining)
426
+ raise ConnectionError, "Connection closed by server" if chunk.nil? || chunk.empty?
427
+
428
+ data << chunk
429
+ remaining -= chunk.bytesize
430
+ end
431
+
432
+ data.unpack("C*")
433
+ end
434
+
435
+ # Read complex samples synchronously
436
+ #
437
+ # @param count [Integer] Number of complex samples to read
438
+ # @return [Array<Complex>] Array of complex samples
439
+ def read_samples(count = 1024)
440
+ # RTL-SDR outputs 8-bit I/Q samples, so we need 2 bytes per complex sample
441
+ data = read_sync(count * 2)
442
+
443
+ # Convert to complex numbers (I + jQ)
444
+ samples = []
445
+ (0...data.length).step(2) do |i|
446
+ i_sample = (data[i] - 128) / 128.0
447
+ q_sample = (data[i + 1] - 128) / 128.0
448
+ samples << Complex(i_sample, q_sample)
449
+ end
450
+
451
+ samples
452
+ end
453
+
454
+ # Check if streaming
455
+ #
456
+ # @return [Boolean] true if streaming
457
+ def streaming?
458
+ @streaming
459
+ end
460
+
461
+ # Read raw IQ data asynchronously
462
+ #
463
+ # @param buffer_count [Integer] Ignored for TCP
464
+ # @param buffer_length [Integer] Buffer size in bytes
465
+ # @yield [Array<Integer>] Block called with each buffer
466
+ # @return [Thread] Background reading thread
467
+ def read_async(buffer_count: 15, buffer_length: 262_144, &block)
468
+ raise ArgumentError, "Block required for async reading" unless block_given?
469
+ raise OperationFailedError, "Already streaming" if streaming?
470
+
471
+ _ = buffer_count # unused for TCP
472
+ @streaming = true
473
+
474
+ Thread.new do
475
+ while @streaming && open?
476
+ begin
477
+ data = read_sync(buffer_length)
478
+ block.call(data)
479
+ rescue ConnectionError => e
480
+ @streaming = false
481
+ raise e unless e.message.include?("closed")
482
+ rescue StandardError => e
483
+ puts "Error in async callback: #{e.message}"
484
+ @streaming = false
485
+ end
486
+ end
487
+ end
488
+ end
489
+
490
+ # Read complex samples asynchronously
491
+ #
492
+ # @param buffer_count [Integer] Ignored for TCP
493
+ # @param buffer_length [Integer] Buffer size in bytes
494
+ # @yield [Array<Complex>] Block called with complex samples
495
+ # @return [Thread] Background reading thread
496
+ def read_samples_async(buffer_count: 15, buffer_length: 262_144, &block)
497
+ raise ArgumentError, "Block required for async reading" unless block_given?
498
+
499
+ read_async(buffer_count: buffer_count, buffer_length: buffer_length) do |data|
500
+ samples = []
501
+ (0...data.length).step(2) do |i|
502
+ i_sample = (data[i] - 128) / 128.0
503
+ q_sample = (data[i + 1] - 128) / 128.0
504
+ samples << Complex(i_sample, q_sample)
505
+ end
506
+
507
+ block.call(samples)
508
+ end
509
+ end
510
+
511
+ # Cancel asynchronous reading
512
+ #
513
+ # @return [void]
514
+ def cancel_async
515
+ @streaming = false
516
+ end
517
+
518
+ # Enumerable interface
519
+ def each(samples_per_read: 1024)
520
+ return enum_for(:each, samples_per_read: samples_per_read) unless block_given?
521
+
522
+ loop do
523
+ samples = read_samples(samples_per_read)
524
+ yield samples
525
+ rescue StandardError => e
526
+ break if e.is_a?(Interrupt) || e.is_a?(ConnectionError)
527
+
528
+ raise
529
+ end
530
+ end
531
+
532
+ # Configuration shortcut
533
+ def configure(frequency: nil, sample_rate: nil, gain: nil, **options)
534
+ self.center_freq = frequency if frequency
535
+ self.sample_rate = sample_rate if sample_rate
536
+
537
+ if gain
538
+ manual_gain_mode!
539
+ self.tuner_gain = gain
540
+ end
541
+
542
+ options.each do |key, value|
543
+ case key
544
+ when :freq_correction then self.freq_correction = value
545
+ when :agc_mode then self.agc_mode = value
546
+ when :test_mode then self.test_mode = value
547
+ when :bias_tee then self.bias_tee = value
548
+ when :direct_sampling then self.direct_sampling = value
549
+ when :offset_tuning then self.offset_tuning = value
550
+ end
551
+ end
552
+
553
+ self
554
+ end
555
+
556
+ # Device info as hash
557
+ def info
558
+ {
559
+ host: @host,
560
+ port: @port,
561
+ name: name,
562
+ usb_strings: usb_strings,
563
+ tuner_type: @tuner_type,
564
+ tuner_name: tuner_name,
565
+ center_freq: center_freq,
566
+ sample_rate: sample_rate,
567
+ tuner_gain: tuner_gain,
568
+ tuner_gains: tuner_gains,
569
+ freq_correction: freq_correction,
570
+ direct_sampling: direct_sampling,
571
+ offset_tuning: offset_tuning
572
+ }
573
+ end
574
+
575
+ # String representation
576
+ def inspect
577
+ if open?
578
+ "#<RTLSDR::TcpClient:#{object_id.to_s(16)} #{name} tuner=\"#{tuner_name}\" " \
579
+ "freq=#{center_freq}Hz rate=#{sample_rate}Hz>"
580
+ else
581
+ "#<RTLSDR::TcpClient:#{object_id.to_s(16)} #{name} closed>"
582
+ end
583
+ end
584
+
585
+ private
586
+
587
+ # Connect to rtl_tcp server and read header
588
+ #
589
+ # @raise [ConnectionError] if connection or header validation fails
590
+ def connect_to_server
591
+ @socket = Socket.tcp(@host, @port, connect_timeout: @timeout)
592
+ @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
593
+
594
+ # Read 12-byte header: "RTL0" + tuner_type(4) + gain_count(4)
595
+ header = @socket.read(12)
596
+ raise ConnectionError, "Failed to read header from rtl_tcp server" if header.nil? || header.length < 12
597
+
598
+ magic = header[0, 4]
599
+ raise ConnectionError, "Invalid rtl_tcp header magic: expected 'RTL0', got '#{magic}'" unless magic == "RTL0"
600
+
601
+ @tuner_type = header[4, 4].unpack1("N")
602
+ @gain_count = header[8, 4].unpack1("N")
603
+ rescue Errno::ECONNREFUSED
604
+ raise ConnectionError, "Connection refused to #{@host}:#{@port} - is rtl_tcp running?"
605
+ rescue Errno::ETIMEDOUT, Errno::EHOSTUNREACH => e
606
+ raise ConnectionError, "Cannot reach #{@host}:#{@port}: #{e.message}"
607
+ rescue SocketError => e
608
+ raise ConnectionError, "Socket error connecting to #{@host}:#{@port}: #{e.message}"
609
+ end
610
+
611
+ # Send a command to the rtl_tcp server
612
+ #
613
+ # @param cmd [Integer] Command code
614
+ # @param param [Integer] Command parameter
615
+ # @raise [ConnectionError] if send fails
616
+ def send_command(cmd, param)
617
+ raise ConnectionError, "Not connected" unless open?
618
+
619
+ # Command is 5 bytes: 1 byte command + 4 bytes big-endian parameter
620
+ packet = [cmd, param].pack("CN")
621
+ @mutex.synchronize do
622
+ @socket.write(packet)
623
+ end
624
+ rescue Errno::EPIPE, Errno::ECONNRESET => e
625
+ @socket = nil
626
+ raise ConnectionError, "Connection lost: #{e.message}"
627
+ end
628
+ end
629
+ end
@@ -12,5 +12,5 @@ module RTLSDR
12
12
  #
13
13
  # @return [String] Current gem version
14
14
  # @since 0.1.0
15
- VERSION = "0.2.4"
15
+ VERSION = "0.3.0"
16
16
  end
data/lib/rtlsdr.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "rtlsdr/version"
4
4
  require_relative "rtlsdr/ffi"
5
5
  require_relative "rtlsdr/fftw"
6
6
  require_relative "rtlsdr/device"
7
+ require_relative "rtlsdr/tcp_client"
7
8
  require_relative "rtlsdr/errors"
8
9
  require_relative "rtlsdr/dsp"
9
10
  require_relative "rtlsdr/dsp/filter"
@@ -25,7 +26,7 @@ require_relative "rtlsdr/scanner"
25
26
  # * Frequency scanning and spectrum analysis
26
27
  # * EEPROM reading/writing and bias tee control
27
28
  #
28
- # @example Basic usage
29
+ # @example Basic usage with local device
29
30
  # device = RTLSDR.open(0)
30
31
  # device.sample_rate = 2_048_000
31
32
  # device.center_freq = 100_000_000
@@ -33,6 +34,13 @@ require_relative "rtlsdr/scanner"
33
34
  # samples = device.read_samples(1024)
34
35
  # device.close
35
36
  #
37
+ # @example Connect to remote rtl_tcp server
38
+ # device = RTLSDR.connect("192.168.1.100", 1234)
39
+ # device.sample_rate = 2_048_000
40
+ # device.center_freq = 100_000_000
41
+ # samples = device.read_samples(1024)
42
+ # device.close
43
+ #
36
44
  # @example List all devices
37
45
  # RTLSDR.devices.each do |dev|
38
46
  # puts "#{dev[:index]}: #{dev[:name]}"
@@ -119,6 +127,27 @@ module RTLSDR
119
127
  Device.new(index)
120
128
  end
121
129
 
130
+ # Connect to a remote rtl_tcp server
131
+ #
132
+ # Creates and returns a TcpClient instance connected to the specified
133
+ # rtl_tcp server. The client implements the same interface as Device,
134
+ # allowing seamless switching between local and remote devices.
135
+ #
136
+ # @param [String] host Hostname or IP address of rtl_tcp server
137
+ # @param [Integer] port Port number (default: 1234)
138
+ # @param [Integer] timeout Connection timeout in seconds (default: 10)
139
+ # @return [RTLSDR::TcpClient] TcpClient instance
140
+ # @raise [ConnectionError] if connection fails
141
+ # @example Connect to remote device
142
+ # device = RTLSDR.connect("192.168.1.100")
143
+ # device.center_freq = 100_000_000
144
+ # @example Connect with custom port
145
+ # device = RTLSDR.connect("192.168.1.100", 5555)
146
+ # @since 0.3.0
147
+ def connect(host, port = TcpClient::DEFAULT_PORT, timeout: 10)
148
+ TcpClient.new(host, port, timeout: timeout)
149
+ end
150
+
122
151
  # List all available devices with their information
123
152
  #
124
153
  # Returns an array of hashes containing information about all connected
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rtlsdr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - joshfng
@@ -57,6 +57,7 @@ files:
57
57
  - lib/rtlsdr/ffi.rb
58
58
  - lib/rtlsdr/fftw.rb
59
59
  - lib/rtlsdr/scanner.rb
60
+ - lib/rtlsdr/tcp_client.rb
60
61
  - lib/rtlsdr/version.rb
61
62
  homepage: https://github.com/joshfng/rtlsdr
62
63
  licenses: