rtlsdr 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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +34 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +397 -0
- data/Rakefile +33 -0
- data/examples/basic_usage.rb +120 -0
- data/examples/spectrum_analyzer.rb +175 -0
- data/exe/rtlsdr +4 -0
- data/lib/rtlsdr/device.rb +482 -0
- data/lib/rtlsdr/dsp.rb +119 -0
- data/lib/rtlsdr/errors.rb +12 -0
- data/lib/rtlsdr/ffi.rb +138 -0
- data/lib/rtlsdr/scanner.rb +151 -0
- data/lib/rtlsdr/version.rb +5 -0
- data/lib/rtlsdr.rb +89 -0
- metadata +78 -0
@@ -0,0 +1,175 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative "../lib/rtlsdr"
|
5
|
+
|
6
|
+
# Simple spectrum analyzer example
|
7
|
+
class SpectrumAnalyzer
|
8
|
+
def initialize(device_index = 0)
|
9
|
+
@device = RTLSDR.open(device_index)
|
10
|
+
puts "Opened device: #{@device.inspect}"
|
11
|
+
|
12
|
+
# Configure device
|
13
|
+
@device.configure(
|
14
|
+
frequency: 100_000_000, # 100 MHz
|
15
|
+
sample_rate: 2_048_000, # 2.048 MHz
|
16
|
+
gain: 400, # 40.0 dB
|
17
|
+
agc_mode: false
|
18
|
+
)
|
19
|
+
|
20
|
+
puts "Device info:"
|
21
|
+
puts " Tuner: #{@device.tuner_name}"
|
22
|
+
puts " Center frequency: #{@device.center_freq / 1e6} MHz"
|
23
|
+
puts " Sample rate: #{@device.sample_rate / 1e6} MHz"
|
24
|
+
puts " Gain: #{@device.tuner_gain / 10.0} dB"
|
25
|
+
puts " Available gains: #{@device.tuner_gains.map { |g| g / 10.0 }} dB"
|
26
|
+
end
|
27
|
+
|
28
|
+
def analyze_current_frequency(samples_count = 8192)
|
29
|
+
puts "\nAnalyzing #{samples_count} samples at #{@device.center_freq / 1e6} MHz..."
|
30
|
+
|
31
|
+
samples = @device.read_samples(samples_count)
|
32
|
+
|
33
|
+
avg_power = RTLSDR::DSP.average_power(samples)
|
34
|
+
power_db = 10 * Math.log10(avg_power + 1e-10)
|
35
|
+
|
36
|
+
magnitude = RTLSDR::DSP.magnitude(samples)
|
37
|
+
RTLSDR::DSP.phase(samples)
|
38
|
+
|
39
|
+
puts " Average power: #{power_db.round(2)} dB"
|
40
|
+
puts " Peak magnitude: #{magnitude.max.round(4)}"
|
41
|
+
puts " Sample count: #{samples.length}"
|
42
|
+
|
43
|
+
# Estimate frequency content
|
44
|
+
freq_est = RTLSDR::DSP.estimate_frequency(samples, @device.sample_rate)
|
45
|
+
puts " Estimated frequency offset: #{freq_est.round(2)} Hz"
|
46
|
+
|
47
|
+
samples
|
48
|
+
end
|
49
|
+
|
50
|
+
def frequency_sweep(start_freq, end_freq, step = 1_000_000)
|
51
|
+
scanner = RTLSDR::Scanner.new(@device,
|
52
|
+
start_freq: start_freq,
|
53
|
+
end_freq: end_freq,
|
54
|
+
step_size: step,
|
55
|
+
dwell_time: 0.05)
|
56
|
+
|
57
|
+
puts "\nPerforming frequency sweep from #{start_freq / 1e6} MHz to #{end_freq / 1e6} MHz"
|
58
|
+
puts "Step size: #{step / 1e6} MHz"
|
59
|
+
puts "Frequencies to scan: #{scanner.frequency_count}"
|
60
|
+
|
61
|
+
results = scanner.power_sweep(samples_per_freq: 2048)
|
62
|
+
|
63
|
+
puts "\nSweep results (top 10 strongest signals):"
|
64
|
+
results.sort_by { |_freq, power| -power }.first(10).each do |freq, power_db|
|
65
|
+
puts " #{(freq / 1e6).round(3)} MHz: #{power_db.round(2)} dB"
|
66
|
+
end
|
67
|
+
|
68
|
+
results
|
69
|
+
end
|
70
|
+
|
71
|
+
def find_active_frequencies(start_freq, end_freq, threshold = -50)
|
72
|
+
scanner = RTLSDR::Scanner.new(@device,
|
73
|
+
start_freq: start_freq,
|
74
|
+
end_freq: end_freq,
|
75
|
+
step_size: 500_000)
|
76
|
+
|
77
|
+
puts "\nScanning for active frequencies above #{threshold} dB..."
|
78
|
+
peaks = scanner.find_peaks(threshold: threshold, samples_per_freq: 4096)
|
79
|
+
|
80
|
+
if peaks.empty?
|
81
|
+
puts "No signals found above #{threshold} dB threshold"
|
82
|
+
else
|
83
|
+
puts "Found #{peaks.length} active frequencies:"
|
84
|
+
peaks.each do |peak|
|
85
|
+
puts " #{(peak[:frequency] / 1e6).round(3)} MHz: #{peak[:power_db].round(2)} dB"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
peaks
|
90
|
+
end
|
91
|
+
|
92
|
+
def monitor_frequency(frequency = 100 * 1e6, duration = 1)
|
93
|
+
puts "\nMonitoring #{frequency / 1e6} MHz for #{duration} seconds..."
|
94
|
+
|
95
|
+
start_time = Time.now
|
96
|
+
sample_count = 0
|
97
|
+
monitoring = true
|
98
|
+
|
99
|
+
# Start async reading in a separate thread
|
100
|
+
monitoring_thread = @device.read_samples_async(buffer_count: 8, buffer_length: 32_768) do |samples|
|
101
|
+
next unless monitoring
|
102
|
+
|
103
|
+
sample_count += samples.length
|
104
|
+
elapsed = Time.now - start_time
|
105
|
+
|
106
|
+
if elapsed >= duration
|
107
|
+
monitoring = false
|
108
|
+
next
|
109
|
+
end
|
110
|
+
|
111
|
+
avg_power = RTLSDR::DSP.average_power(samples)
|
112
|
+
power_db = 10 * Math.log10(avg_power + 1e-10)
|
113
|
+
|
114
|
+
print "\rTime: #{elapsed.round(1)}s, Samples: #{sample_count}, Power: #{power_db.round(2)} dB"
|
115
|
+
$stdout.flush
|
116
|
+
end
|
117
|
+
|
118
|
+
# Wait for completion or timeout
|
119
|
+
start_wait = Time.now
|
120
|
+
sleep(0.1) while monitoring && (Time.now - start_wait) < (duration + 1.0)
|
121
|
+
|
122
|
+
# Cancel if still running
|
123
|
+
if @device.streaming?
|
124
|
+
@device.cancel_async
|
125
|
+
monitoring_thread&.join(1.0) # Wait up to 1 second for clean shutdown
|
126
|
+
end
|
127
|
+
|
128
|
+
puts "\nMonitoring complete. Total samples: #{sample_count}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def close
|
132
|
+
@device&.close
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Main execution
|
137
|
+
if __FILE__ == $PROGRAM_NAME
|
138
|
+
begin
|
139
|
+
puts "RTL-SDR Spectrum Analyzer Example"
|
140
|
+
puts "================================="
|
141
|
+
|
142
|
+
# Check for devices
|
143
|
+
puts "Available RTL-SDR devices:"
|
144
|
+
RTLSDR.devices.each_with_index do |device, i|
|
145
|
+
puts " #{i}: #{device[:name]} (#{device[:usb_strings][:manufacturer]} #{device[:usb_strings][:product]})"
|
146
|
+
end
|
147
|
+
|
148
|
+
if RTLSDR.device_count.zero?
|
149
|
+
puts "No RTL-SDR devices found!"
|
150
|
+
exit 1
|
151
|
+
end
|
152
|
+
|
153
|
+
analyzer = SpectrumAnalyzer.new(0)
|
154
|
+
|
155
|
+
analyzer.analyze_current_frequency(4096)
|
156
|
+
|
157
|
+
analyzer.frequency_sweep(88_000_000, 108_000_000, 200_000)
|
158
|
+
|
159
|
+
frequencies = analyzer.find_active_frequencies(88_000_000, 108_000_000, -60)
|
160
|
+
|
161
|
+
frequencies.each do |freq|
|
162
|
+
analyzer.monitor_frequency(freq[:frequency], 1)
|
163
|
+
end
|
164
|
+
|
165
|
+
analyzer.monitor_frequency(5)
|
166
|
+
rescue RTLSDR::Error => e
|
167
|
+
puts "RTL-SDR Error: #{e.message}"
|
168
|
+
exit 1
|
169
|
+
rescue Interrupt
|
170
|
+
puts "\nInterrupted by user"
|
171
|
+
ensure
|
172
|
+
analyzer&.close
|
173
|
+
puts "Device closed."
|
174
|
+
end
|
175
|
+
end
|
data/exe/rtlsdr
ADDED
@@ -0,0 +1,482 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ffi"
|
4
|
+
|
5
|
+
module RTLSDR
|
6
|
+
# High-level interface to RTL-SDR devices
|
7
|
+
#
|
8
|
+
# The Device class provides a Ruby-idiomatic interface to RTL-SDR dongles,
|
9
|
+
# wrapping the low-level librtlsdr C API with convenient methods and automatic
|
10
|
+
# resource management. It supports both synchronous and asynchronous sample
|
11
|
+
# reading, comprehensive device configuration, and implements Enumerable for
|
12
|
+
# streaming operations.
|
13
|
+
#
|
14
|
+
# Features:
|
15
|
+
# * Automatic device lifecycle management (open/close)
|
16
|
+
# * Frequency, gain, and sample rate control with validation
|
17
|
+
# * Multiple gain modes (manual, automatic, AGC)
|
18
|
+
# * Synchronous and asynchronous sample reading
|
19
|
+
# * IQ sample conversion to Ruby Complex numbers
|
20
|
+
# * EEPROM access and bias tee control
|
21
|
+
# * Enumerable interface for continuous streaming
|
22
|
+
# * Comprehensive error handling with custom exceptions
|
23
|
+
#
|
24
|
+
# @example Basic device setup
|
25
|
+
# device = RTLSDR::Device.new(0)
|
26
|
+
# device.configure(
|
27
|
+
# frequency: 100_000_000, # 100 MHz
|
28
|
+
# sample_rate: 2_048_000, # 2.048 MSPS
|
29
|
+
# gain: 496 # 49.6 dB
|
30
|
+
# )
|
31
|
+
#
|
32
|
+
# @example Streaming samples
|
33
|
+
# device.each(samples_per_read: 1024) do |samples|
|
34
|
+
# power = RTLSDR::DSP.average_power(samples)
|
35
|
+
# puts "Average power: #{power}"
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# @example Asynchronous reading
|
39
|
+
# device.read_samples_async do |samples|
|
40
|
+
# # Process samples in real-time
|
41
|
+
# spectrum = RTLSDR::DSP.power_spectrum(samples)
|
42
|
+
# end
|
43
|
+
class Device
|
44
|
+
include Enumerable
|
45
|
+
|
46
|
+
attr_reader :index, :handle
|
47
|
+
|
48
|
+
def initialize(index = 0)
|
49
|
+
@index = index
|
50
|
+
@handle = nil
|
51
|
+
@streaming = false
|
52
|
+
@async_thread = nil
|
53
|
+
@buffer_reset_done = false
|
54
|
+
open_device
|
55
|
+
end
|
56
|
+
|
57
|
+
# Device lifecycle
|
58
|
+
def open?
|
59
|
+
!@handle.nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
def close
|
63
|
+
return unless open?
|
64
|
+
|
65
|
+
cancel_async if streaming?
|
66
|
+
result = FFI.rtlsdr_close(@handle)
|
67
|
+
@handle = nil
|
68
|
+
check_result(result, "Failed to close device")
|
69
|
+
end
|
70
|
+
|
71
|
+
def closed?
|
72
|
+
!open?
|
73
|
+
end
|
74
|
+
|
75
|
+
# Device information
|
76
|
+
def name
|
77
|
+
RTLSDR.device_name(@index)
|
78
|
+
end
|
79
|
+
|
80
|
+
def usb_strings
|
81
|
+
return @usb_strings if @usb_strings
|
82
|
+
|
83
|
+
manufact = " " * 256
|
84
|
+
product = " " * 256
|
85
|
+
serial = " " * 256
|
86
|
+
|
87
|
+
result = FFI.rtlsdr_get_usb_strings(@handle, manufact, product, serial)
|
88
|
+
check_result(result, "Failed to get USB strings")
|
89
|
+
|
90
|
+
@usb_strings = {
|
91
|
+
manufacturer: manufact.strip,
|
92
|
+
product: product.strip,
|
93
|
+
serial: serial.strip
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
def tuner_type
|
98
|
+
@tuner_type ||= FFI.rtlsdr_get_tuner_type(@handle)
|
99
|
+
end
|
100
|
+
|
101
|
+
def tuner_name
|
102
|
+
FFI.tuner_type_name(tuner_type)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Frequency control
|
106
|
+
def center_freq=(freq)
|
107
|
+
result = FFI.rtlsdr_set_center_freq(@handle, freq)
|
108
|
+
check_result(result, "Failed to set center frequency")
|
109
|
+
end
|
110
|
+
|
111
|
+
def center_freq
|
112
|
+
FFI.rtlsdr_get_center_freq(@handle)
|
113
|
+
end
|
114
|
+
alias frequency center_freq
|
115
|
+
alias frequency= center_freq=
|
116
|
+
|
117
|
+
def freq_correction=(ppm)
|
118
|
+
result = FFI.rtlsdr_set_freq_correction(@handle, ppm)
|
119
|
+
check_result(result, "Failed to set frequency correction")
|
120
|
+
end
|
121
|
+
|
122
|
+
def freq_correction
|
123
|
+
FFI.rtlsdr_get_freq_correction(@handle)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Crystal oscillator frequencies
|
127
|
+
def set_xtal_freq(rtl_freq, tuner_freq)
|
128
|
+
result = FFI.rtlsdr_set_xtal_freq(@handle, rtl_freq, tuner_freq)
|
129
|
+
check_result(result, "Failed to set crystal frequencies")
|
130
|
+
[rtl_freq, tuner_freq]
|
131
|
+
end
|
132
|
+
|
133
|
+
def xtal_freq
|
134
|
+
rtl_freq_ptr = ::FFI::MemoryPointer.new(:uint32)
|
135
|
+
tuner_freq_ptr = ::FFI::MemoryPointer.new(:uint32)
|
136
|
+
|
137
|
+
result = FFI.rtlsdr_get_xtal_freq(@handle, rtl_freq_ptr, tuner_freq_ptr)
|
138
|
+
check_result(result, "Failed to get crystal frequencies")
|
139
|
+
|
140
|
+
[rtl_freq_ptr.read_uint32, tuner_freq_ptr.read_uint32]
|
141
|
+
end
|
142
|
+
|
143
|
+
# Gain control
|
144
|
+
def tuner_gains
|
145
|
+
# First call to get count
|
146
|
+
count = FFI.rtlsdr_get_tuner_gains(@handle, nil)
|
147
|
+
return [] if count <= 0
|
148
|
+
|
149
|
+
# Second call to get actual gains
|
150
|
+
gains_ptr = ::FFI::MemoryPointer.new(:int, count)
|
151
|
+
result = FFI.rtlsdr_get_tuner_gains(@handle, gains_ptr)
|
152
|
+
return [] if result <= 0
|
153
|
+
|
154
|
+
gains_ptr.read_array_of_int(result)
|
155
|
+
end
|
156
|
+
|
157
|
+
def tuner_gain=(gain)
|
158
|
+
result = FFI.rtlsdr_set_tuner_gain(@handle, gain)
|
159
|
+
check_result(result, "Failed to set tuner gain")
|
160
|
+
end
|
161
|
+
|
162
|
+
def tuner_gain
|
163
|
+
FFI.rtlsdr_get_tuner_gain(@handle)
|
164
|
+
end
|
165
|
+
alias gain tuner_gain
|
166
|
+
alias gain= tuner_gain=
|
167
|
+
|
168
|
+
def tuner_gain_mode=(manual)
|
169
|
+
mode = manual ? 1 : 0
|
170
|
+
result = FFI.rtlsdr_set_tuner_gain_mode(@handle, mode)
|
171
|
+
check_result(result, "Failed to set gain mode")
|
172
|
+
end
|
173
|
+
|
174
|
+
def manual_gain_mode!
|
175
|
+
self.tuner_gain_mode = true
|
176
|
+
end
|
177
|
+
|
178
|
+
def auto_gain_mode!
|
179
|
+
self.tuner_gain_mode = false
|
180
|
+
end
|
181
|
+
|
182
|
+
def set_tuner_if_gain(stage, gain)
|
183
|
+
result = FFI.rtlsdr_set_tuner_if_gain(@handle, stage, gain)
|
184
|
+
check_result(result, "Failed to set IF gain")
|
185
|
+
gain
|
186
|
+
end
|
187
|
+
|
188
|
+
def tuner_bandwidth=(bw)
|
189
|
+
result = FFI.rtlsdr_set_tuner_bandwidth(@handle, bw)
|
190
|
+
check_result(result, "Failed to set bandwidth")
|
191
|
+
end
|
192
|
+
|
193
|
+
# Sample rate control
|
194
|
+
def sample_rate=(rate)
|
195
|
+
result = FFI.rtlsdr_set_sample_rate(@handle, rate)
|
196
|
+
check_result(result, "Failed to set sample rate")
|
197
|
+
end
|
198
|
+
|
199
|
+
def sample_rate
|
200
|
+
FFI.rtlsdr_get_sample_rate(@handle)
|
201
|
+
end
|
202
|
+
alias samp_rate sample_rate
|
203
|
+
alias samp_rate= sample_rate=
|
204
|
+
|
205
|
+
# Mode control
|
206
|
+
def test_mode=(enabled)
|
207
|
+
mode = enabled ? 1 : 0
|
208
|
+
result = FFI.rtlsdr_set_testmode(@handle, mode)
|
209
|
+
check_result(result, "Failed to set test mode")
|
210
|
+
end
|
211
|
+
|
212
|
+
def test_mode!
|
213
|
+
self.test_mode = true
|
214
|
+
end
|
215
|
+
|
216
|
+
def agc_mode=(enabled)
|
217
|
+
mode = enabled ? 1 : 0
|
218
|
+
result = FFI.rtlsdr_set_agc_mode(@handle, mode)
|
219
|
+
check_result(result, "Failed to set AGC mode")
|
220
|
+
end
|
221
|
+
|
222
|
+
def agc_mode!
|
223
|
+
self.agc_mode = true
|
224
|
+
end
|
225
|
+
|
226
|
+
def direct_sampling=(mode)
|
227
|
+
result = FFI.rtlsdr_set_direct_sampling(@handle, mode)
|
228
|
+
check_result(result, "Failed to set direct sampling")
|
229
|
+
end
|
230
|
+
|
231
|
+
def direct_sampling
|
232
|
+
result = FFI.rtlsdr_get_direct_sampling(@handle)
|
233
|
+
return nil if result.negative?
|
234
|
+
|
235
|
+
result
|
236
|
+
end
|
237
|
+
|
238
|
+
def offset_tuning=(enabled)
|
239
|
+
mode = enabled ? 1 : 0
|
240
|
+
result = FFI.rtlsdr_set_offset_tuning(@handle, mode)
|
241
|
+
check_result(result, "Failed to set offset tuning")
|
242
|
+
end
|
243
|
+
|
244
|
+
def offset_tuning
|
245
|
+
result = FFI.rtlsdr_get_offset_tuning(@handle)
|
246
|
+
return nil if result.negative?
|
247
|
+
|
248
|
+
result == 1
|
249
|
+
end
|
250
|
+
|
251
|
+
def offset_tuning!
|
252
|
+
self.offset_tuning = true
|
253
|
+
end
|
254
|
+
|
255
|
+
# Bias tee control
|
256
|
+
def bias_tee=(enabled)
|
257
|
+
mode = enabled ? 1 : 0
|
258
|
+
result = FFI.rtlsdr_set_bias_tee(@handle, mode)
|
259
|
+
check_result(result, "Failed to set bias tee")
|
260
|
+
end
|
261
|
+
|
262
|
+
def bias_tee!
|
263
|
+
self.bias_tee = true
|
264
|
+
end
|
265
|
+
|
266
|
+
def set_bias_tee_gpio(gpio, enabled)
|
267
|
+
mode = enabled ? 1 : 0
|
268
|
+
result = FFI.rtlsdr_set_bias_tee_gpio(@handle, gpio, mode)
|
269
|
+
check_result(result, "Failed to set bias tee GPIO")
|
270
|
+
enabled
|
271
|
+
end
|
272
|
+
|
273
|
+
# EEPROM access
|
274
|
+
def read_eeprom(offset, length)
|
275
|
+
data_ptr = ::FFI::MemoryPointer.new(:uint8, length)
|
276
|
+
result = FFI.rtlsdr_read_eeprom(@handle, data_ptr, offset, length)
|
277
|
+
check_result(result, "Failed to read EEPROM")
|
278
|
+
data_ptr.read_array_of_uint8(length)
|
279
|
+
end
|
280
|
+
|
281
|
+
def write_eeprom(data, offset)
|
282
|
+
data_ptr = ::FFI::MemoryPointer.new(:uint8, data.length)
|
283
|
+
data_ptr.write_array_of_uint8(data)
|
284
|
+
result = FFI.rtlsdr_write_eeprom(@handle, data_ptr, offset, data.length)
|
285
|
+
check_result(result, "Failed to write EEPROM")
|
286
|
+
data.length
|
287
|
+
end
|
288
|
+
|
289
|
+
# Streaming control
|
290
|
+
def reset_buffer
|
291
|
+
result = FFI.rtlsdr_reset_buffer(@handle)
|
292
|
+
check_result(result, "Failed to reset buffer")
|
293
|
+
end
|
294
|
+
|
295
|
+
def read_sync(length)
|
296
|
+
# Reset buffer before first read to avoid stale data
|
297
|
+
reset_buffer unless @buffer_reset_done
|
298
|
+
@buffer_reset_done = true
|
299
|
+
|
300
|
+
buffer = ::FFI::MemoryPointer.new(:uint8, length)
|
301
|
+
n_read_ptr = ::FFI::MemoryPointer.new(:int)
|
302
|
+
|
303
|
+
result = FFI.rtlsdr_read_sync(@handle, buffer, length, n_read_ptr)
|
304
|
+
check_result(result, "Failed to read synchronously")
|
305
|
+
|
306
|
+
n_read = n_read_ptr.read_int
|
307
|
+
buffer.read_array_of_uint8(n_read)
|
308
|
+
end
|
309
|
+
|
310
|
+
def read_samples(count = 1024)
|
311
|
+
# RTL-SDR outputs 8-bit I/Q samples, so we need 2 bytes per complex sample
|
312
|
+
data = read_sync(count * 2)
|
313
|
+
|
314
|
+
# Convert to complex numbers (I + jQ)
|
315
|
+
samples = []
|
316
|
+
(0...data.length).step(2) do |i|
|
317
|
+
i_sample = (data[i] - 128) / 128.0 # Convert to -1.0 to 1.0 range
|
318
|
+
q_sample = (data[i + 1] - 128) / 128.0 # Convert to -1.0 to 1.0 range
|
319
|
+
samples << Complex(i_sample, q_sample)
|
320
|
+
end
|
321
|
+
|
322
|
+
samples
|
323
|
+
end
|
324
|
+
|
325
|
+
def streaming?
|
326
|
+
@streaming
|
327
|
+
end
|
328
|
+
|
329
|
+
def read_async(buffer_count: 15, buffer_length: 262_144, &block)
|
330
|
+
raise ArgumentError, "Block required for async reading" unless block_given?
|
331
|
+
raise OperationFailedError, "Already streaming" if streaming?
|
332
|
+
|
333
|
+
@streaming = true
|
334
|
+
@async_callback = proc do |buf_ptr, len, _ctx|
|
335
|
+
data = buf_ptr.read_array_of_uint8(len)
|
336
|
+
block.call(data)
|
337
|
+
rescue StandardError => e
|
338
|
+
puts "Error in async callback: #{e.message}"
|
339
|
+
cancel_async
|
340
|
+
end
|
341
|
+
|
342
|
+
@async_thread = Thread.new do
|
343
|
+
result = FFI.rtlsdr_read_async(@handle, @async_callback, nil, buffer_count, buffer_length)
|
344
|
+
@streaming = false
|
345
|
+
# Don't raise error for cancellation (-1) or timeout (-5)
|
346
|
+
check_result(result, "Async read failed") unless [-1, -5].include?(result)
|
347
|
+
end
|
348
|
+
|
349
|
+
@async_thread
|
350
|
+
end
|
351
|
+
|
352
|
+
def read_samples_async(buffer_count: 15, buffer_length: 262_144, &block)
|
353
|
+
raise ArgumentError, "Block required for async reading" unless block_given?
|
354
|
+
|
355
|
+
read_async(buffer_count: buffer_count, buffer_length: buffer_length) do |data|
|
356
|
+
# Convert to complex samples
|
357
|
+
samples = []
|
358
|
+
(0...data.length).step(2) do |i|
|
359
|
+
i_sample = (data[i] - 128) / 128.0
|
360
|
+
q_sample = (data[i + 1] - 128) / 128.0
|
361
|
+
samples << Complex(i_sample, q_sample)
|
362
|
+
end
|
363
|
+
|
364
|
+
block.call(samples)
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
def cancel_async
|
369
|
+
return unless streaming?
|
370
|
+
|
371
|
+
result = FFI.rtlsdr_cancel_async(@handle)
|
372
|
+
@streaming = false
|
373
|
+
|
374
|
+
# Only join if we're not calling from within the async thread itself
|
375
|
+
if @async_thread && @async_thread != Thread.current
|
376
|
+
@async_thread.join(1) # Wait up to 1 second for thread to finish
|
377
|
+
end
|
378
|
+
|
379
|
+
@async_thread = nil
|
380
|
+
@async_callback = nil
|
381
|
+
|
382
|
+
check_result(result, "Failed to cancel async operation")
|
383
|
+
end
|
384
|
+
|
385
|
+
# Enumerable interface for reading samples
|
386
|
+
def each(samples_per_read: 1024)
|
387
|
+
return enum_for(:each, samples_per_read: samples_per_read) unless block_given?
|
388
|
+
|
389
|
+
loop do
|
390
|
+
samples = read_samples(samples_per_read)
|
391
|
+
yield samples
|
392
|
+
rescue StandardError => e
|
393
|
+
break if e.is_a?(Interrupt)
|
394
|
+
|
395
|
+
raise
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
# Configuration shortcuts
|
400
|
+
def configure(frequency: nil, sample_rate: nil, gain: nil, **options)
|
401
|
+
self.center_freq = frequency if frequency
|
402
|
+
self.sample_rate = sample_rate if sample_rate
|
403
|
+
|
404
|
+
if gain
|
405
|
+
manual_gain_mode!
|
406
|
+
self.tuner_gain = gain
|
407
|
+
end
|
408
|
+
|
409
|
+
options.each do |key, value|
|
410
|
+
case key
|
411
|
+
when :freq_correction then self.freq_correction = value
|
412
|
+
when :bandwidth then self.tuner_bandwidth = value
|
413
|
+
when :agc_mode then self.agc_mode = value
|
414
|
+
when :test_mode then self.test_mode = value
|
415
|
+
when :bias_tee then self.bias_tee = value
|
416
|
+
when :direct_sampling then self.direct_sampling = value
|
417
|
+
when :offset_tuning then self.offset_tuning = value
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
self
|
422
|
+
end
|
423
|
+
|
424
|
+
# Device info as hash
|
425
|
+
def info
|
426
|
+
{
|
427
|
+
index: @index,
|
428
|
+
name: name,
|
429
|
+
usb_strings: usb_strings,
|
430
|
+
tuner_type: tuner_type,
|
431
|
+
tuner_name: tuner_name,
|
432
|
+
center_freq: center_freq,
|
433
|
+
sample_rate: sample_rate,
|
434
|
+
tuner_gain: tuner_gain,
|
435
|
+
tuner_gains: tuner_gains,
|
436
|
+
freq_correction: freq_correction,
|
437
|
+
direct_sampling: direct_sampling,
|
438
|
+
offset_tuning: offset_tuning
|
439
|
+
}
|
440
|
+
end
|
441
|
+
|
442
|
+
def inspect
|
443
|
+
if open?
|
444
|
+
"#<RTLSDR::Device:#{object_id.to_s(16)} index=#{@index} name=\"#{name}\" tuner=\"#{tuner_name}\" freq=#{center_freq}Hz rate=#{sample_rate}Hz>" # rubocop:disable Layout/LineLength
|
445
|
+
else
|
446
|
+
"#<RTLSDR::Device:#{object_id.to_s(16)} index=#{@index} closed>"
|
447
|
+
end
|
448
|
+
end
|
449
|
+
|
450
|
+
private
|
451
|
+
|
452
|
+
def open_device
|
453
|
+
dev_ptr = ::FFI::MemoryPointer.new(:pointer)
|
454
|
+
result = FFI.rtlsdr_open(dev_ptr, @index)
|
455
|
+
|
456
|
+
case result
|
457
|
+
when 0
|
458
|
+
@handle = dev_ptr.read_pointer
|
459
|
+
when -1
|
460
|
+
raise DeviceNotFoundError, "Device #{@index} not found"
|
461
|
+
when -2
|
462
|
+
raise DeviceOpenError, "Device #{@index} already in use"
|
463
|
+
when -3
|
464
|
+
raise DeviceOpenError, "Device #{@index} cannot be opened"
|
465
|
+
else
|
466
|
+
raise DeviceOpenError, "Failed to open device #{@index}: error #{result}"
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
def check_result(result, message)
|
471
|
+
return if result.zero?
|
472
|
+
|
473
|
+
error_msg = "#{message}: error #{result}"
|
474
|
+
case result
|
475
|
+
when -1 then raise DeviceNotOpenError, error_msg
|
476
|
+
when -2 then raise InvalidArgumentError, error_msg
|
477
|
+
when -3 then raise EEPROMError, error_msg
|
478
|
+
else raise OperationFailedError, error_msg
|
479
|
+
end
|
480
|
+
end
|
481
|
+
end
|
482
|
+
end
|