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 +4 -4
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +13 -0
- data/lib/rtlsdr/errors.rb +8 -0
- data/lib/rtlsdr/ffi.rb +2 -1
- data/lib/rtlsdr/tcp_client.rb +629 -0
- data/lib/rtlsdr/version.rb +1 -1
- data/lib/rtlsdr.rb +30 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 476e8a7b2e6fbc8f34a9522b3bcd203033fa8aa031c87ff9f6a8d65c447520c1
|
|
4
|
+
data.tar.gz: 97ccaa1da588545b2a3e4e739825757cde0364775b660a1bc644034c94bc4a7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
data/lib/rtlsdr/version.rb
CHANGED
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.
|
|
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:
|