pwn 0.5.510 → 0.5.513
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 +1 -1
- data/Gemfile +8 -8
- data/README.md +3 -3
- data/bin/pwn_gqrx_scanner +69 -89
- data/{build_pwn_gem.sh → build_gem.sh} +1 -1
- data/{git_commit_test_reinit_gem.sh → git_commit.sh} +4 -4
- data/lib/pwn/plugins/sock.rb +11 -1
- data/lib/pwn/sdr/decoder/gsm.rb +24 -35
- data/lib/pwn/sdr/frequency_allocation.rb +243 -262
- data/lib/pwn/sdr/gqrx.rb +613 -244
- data/lib/pwn/version.rb +1 -1
- data/packer/provisioners/pwn.sh +1 -1
- data/third_party/pwn_rdoc.jsonl +5 -3
- data/{update_pwn.sh → upgrade_pwn.sh} +1 -1
- data/upgrade_ruby.sh +2 -2
- data/vagrant/provisioners/pwn.sh +1 -1
- metadata +23 -23
- /data/{reinstall_pwn_gemset.sh → reinstall_gemset.sh} +0 -0
- /data/{find_latest_gem_versions_per_Gemfile.sh → upgrade_Gemfile_gems.sh} +0 -0
data/lib/pwn/sdr/gqrx.rb
CHANGED
|
@@ -9,7 +9,7 @@ module PWN
|
|
|
9
9
|
module GQRX
|
|
10
10
|
# Monkey patches for frequency handling
|
|
11
11
|
String.class_eval do
|
|
12
|
-
def
|
|
12
|
+
def cast_to_raw_hz
|
|
13
13
|
gsub('.', '').to_i
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -20,30 +20,16 @@ module PWN
|
|
|
20
20
|
# So 2_450_000_000 becomes 2.450.000.000
|
|
21
21
|
# So 960_000_000 becomes 960.000.000
|
|
22
22
|
# 1000 should be 1.000
|
|
23
|
-
def
|
|
23
|
+
def cast_to_pretty_hz
|
|
24
24
|
str_hz = to_s
|
|
25
25
|
# Nuke leading zeros
|
|
26
26
|
# E.g., 002450000000 -> 2450000000
|
|
27
|
-
str_hz = str_hz.sub(/^0+/, '')
|
|
27
|
+
str_hz = str_hz.sub(/^0+/, '') unless str_hz == '0'
|
|
28
28
|
# Insert dots every 3 digits from the right
|
|
29
29
|
str_hz.reverse.scan(/.{1,3}/).join('.').reverse
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Supported Method Parameters::
|
|
34
|
-
# gqrx_sock = PWN::SDR::GQRX.connect(
|
|
35
|
-
# target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
36
|
-
# port: 'optional - GQRX target port (defaults to 7356)'
|
|
37
|
-
# )
|
|
38
|
-
public_class_method def self.connect(opts = {})
|
|
39
|
-
target = opts[:target] ||= '127.0.0.1'
|
|
40
|
-
port = opts[:port] ||= 7356
|
|
41
|
-
|
|
42
|
-
PWN::Plugins::Sock.connect(target: target, port: port)
|
|
43
|
-
rescue StandardError => e
|
|
44
|
-
raise e
|
|
45
|
-
end
|
|
46
|
-
|
|
47
33
|
# Supported Method Parameters::
|
|
48
34
|
# gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
|
|
49
35
|
# gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
@@ -51,17 +37,22 @@ module PWN
|
|
|
51
37
|
# resp_ok: 'optional - Expected response from GQRX to indicate success'
|
|
52
38
|
# )
|
|
53
39
|
|
|
54
|
-
|
|
40
|
+
private_class_method def self.gqrx_cmd(opts = {})
|
|
55
41
|
gqrx_sock = opts[:gqrx_sock]
|
|
56
42
|
cmd = opts[:cmd]
|
|
57
43
|
resp_ok = opts[:resp_ok]
|
|
58
44
|
|
|
59
45
|
# Most Recent GQRX Command Set:
|
|
60
46
|
# https://raw.githubusercontent.com/gqrx-sdr/gqrx/master/resources/remote-control.txt
|
|
47
|
+
# Remote control protocol.
|
|
48
|
+
#
|
|
61
49
|
# Supported commands:
|
|
62
|
-
# f
|
|
63
|
-
#
|
|
64
|
-
#
|
|
50
|
+
# f
|
|
51
|
+
# Get frequency [Hz]
|
|
52
|
+
# F <frequency>
|
|
53
|
+
# Set frequency [Hz]
|
|
54
|
+
# m
|
|
55
|
+
# Get demodulator mode and passband
|
|
65
56
|
# M <mode> [passband]
|
|
66
57
|
# Set demodulator mode and passband [Hz]
|
|
67
58
|
# Passing a '?' as the first argument instead of 'mode' will return
|
|
@@ -84,18 +75,30 @@ module PWN
|
|
|
84
75
|
# Set the value of the gain setting with the name <gain_name> to <value>
|
|
85
76
|
# p RDS_PI
|
|
86
77
|
# Get the RDS PI code (in hexadecimal). Returns 0000 if not applicable.
|
|
78
|
+
# p RDS_PS_NAME
|
|
79
|
+
# Get the RDS Program Service (PS) name
|
|
80
|
+
# p RDS_RADIOTEXT
|
|
81
|
+
# Get the RDS RadioText message
|
|
87
82
|
# u RECORD
|
|
88
83
|
# Get status of audio recorder
|
|
89
84
|
# U RECORD <status>
|
|
90
85
|
# Set status of audio recorder to <status>
|
|
86
|
+
# u IQRECORD
|
|
87
|
+
# Get status of IQ recorder
|
|
88
|
+
# U IQRECORD <status>
|
|
89
|
+
# Set status of IQ recorder to <status>
|
|
91
90
|
# u DSP
|
|
92
91
|
# Get DSP (SDR receiver) status
|
|
93
92
|
# U DSP <status>
|
|
94
93
|
# Set DSP (SDR receiver) status to <status>
|
|
95
94
|
# u RDS
|
|
96
|
-
# Get RDS decoder
|
|
95
|
+
# Get RDS decoder status. Only functions in WFM mode.
|
|
97
96
|
# U RDS <status>
|
|
98
97
|
# Set RDS decoder to <status>. Only functions in WFM mode.
|
|
98
|
+
# u MUTE
|
|
99
|
+
# Get audio mute status
|
|
100
|
+
# U MUTE <status>
|
|
101
|
+
# Set audio mute to <status>
|
|
99
102
|
# q|Q
|
|
100
103
|
# Close connection
|
|
101
104
|
# AOS
|
|
@@ -122,6 +125,7 @@ module PWN
|
|
|
122
125
|
# _
|
|
123
126
|
# Get version
|
|
124
127
|
#
|
|
128
|
+
#
|
|
125
129
|
# Reply:
|
|
126
130
|
# RPRT 0
|
|
127
131
|
# Command successful
|
|
@@ -154,7 +158,7 @@ module PWN
|
|
|
154
158
|
raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
|
|
155
159
|
|
|
156
160
|
# Reformat positive integer frequency responses (e.g., from 'f')
|
|
157
|
-
response_str = response_str.to_i.
|
|
161
|
+
response_str = response_str.to_i.cast_to_pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
|
|
158
162
|
|
|
159
163
|
response_str
|
|
160
164
|
rescue RuntimeError => e
|
|
@@ -170,86 +174,306 @@ module PWN
|
|
|
170
174
|
end
|
|
171
175
|
|
|
172
176
|
# Supported Method Parameters::
|
|
173
|
-
#
|
|
177
|
+
# strength_db = PWN::SDR::GQRX.measure_signal_strength(
|
|
178
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method'
|
|
179
|
+
# )
|
|
180
|
+
private_class_method def self.measure_signal_strength(opts = {})
|
|
181
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
182
|
+
|
|
183
|
+
strength_db = -99.9
|
|
184
|
+
prev_strength_db = strength_db
|
|
185
|
+
# While strength_db is rising, keep measuring
|
|
186
|
+
loop do
|
|
187
|
+
strength_db = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
|
|
188
|
+
break if strength_db <= prev_strength_db
|
|
189
|
+
|
|
190
|
+
prev_strength_db = strength_db
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
strength_db
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
raise e
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Supported Method Parameters::
|
|
199
|
+
# candidate_signals = PWN::SDR::GQRX.edge_detection(
|
|
200
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
201
|
+
# hz: 'required - Frequency to start edge detection from',
|
|
202
|
+
# step_hz: 'required - Frequency step in Hz for edge detection',
|
|
203
|
+
# strength_lock: 'required - Strength lock in dBFS to determine signal edges'
|
|
204
|
+
# )
|
|
205
|
+
private_class_method def self.edge_detection(opts = {})
|
|
206
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
207
|
+
hz = opts[:hz]
|
|
208
|
+
step_hz = opts[:step_hz]
|
|
209
|
+
strength_lock = opts[:strength_lock]
|
|
210
|
+
left_candidate_signals = []
|
|
211
|
+
right_candidate_signals = []
|
|
212
|
+
candidate_signals = []
|
|
213
|
+
|
|
214
|
+
# left_candidate_signals.clear
|
|
215
|
+
original_hz = hz
|
|
216
|
+
strength_db = 99.9
|
|
217
|
+
puts 'Finding Beginning Edge of Signal...'
|
|
218
|
+
while strength_db >= strength_lock
|
|
219
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
|
|
220
|
+
current_freq = 0
|
|
221
|
+
while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
|
|
222
|
+
current_freq = gqrx_cmd(
|
|
223
|
+
gqrx_sock: gqrx_sock,
|
|
224
|
+
cmd: 'f'
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
|
|
228
|
+
candidate = {
|
|
229
|
+
hz: hz.to_s.cast_to_raw_hz,
|
|
230
|
+
freq: hz.to_i.cast_to_pretty_hz,
|
|
231
|
+
strength: strength_db,
|
|
232
|
+
side: :left
|
|
233
|
+
}
|
|
234
|
+
left_candidate_signals.push(candidate)
|
|
235
|
+
hz -= step_hz
|
|
236
|
+
end
|
|
237
|
+
left_candidate_signals.uniq! { |s| s[:hz] }
|
|
238
|
+
left_candidate_signals.sort_by! { |s| s[:hz] }
|
|
239
|
+
|
|
240
|
+
# Now scan forwards to find the end of the signal
|
|
241
|
+
# The end of the signal is where the strength drops below strength_lock
|
|
242
|
+
# right_candidate_signals.clear
|
|
243
|
+
hz = original_hz
|
|
244
|
+
|
|
245
|
+
strength_db = 99.9
|
|
246
|
+
puts 'Finding Ending Edge of Signal...'
|
|
247
|
+
while strength_db >= strength_lock
|
|
248
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
|
|
249
|
+
current_freq = 0
|
|
250
|
+
while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
|
|
251
|
+
current_freq = gqrx_cmd(
|
|
252
|
+
gqrx_sock: gqrx_sock,
|
|
253
|
+
cmd: 'f'
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
|
|
257
|
+
candidate = {
|
|
258
|
+
hz: hz.to_s.cast_to_raw_hz,
|
|
259
|
+
freq: hz.to_i.cast_to_pretty_hz,
|
|
260
|
+
strength: strength_db,
|
|
261
|
+
side: :right
|
|
262
|
+
}
|
|
263
|
+
right_candidate_signals.push(candidate)
|
|
264
|
+
hz += step_hz
|
|
265
|
+
end
|
|
266
|
+
# Update candidate signals to remove duplicates and sort by hz
|
|
267
|
+
right_candidate_signals.uniq! { |s| s[:hz] }
|
|
268
|
+
right_candidate_signals.sort_by! { |s| s[:hz] }
|
|
269
|
+
|
|
270
|
+
candidate_signals = left_candidate_signals + right_candidate_signals
|
|
271
|
+
candidate_signals.uniq! { |s| s[:hz] }
|
|
272
|
+
candidate_signals.sort_by! { |s| s[:hz] }
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
raise e
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Supported Method Parameters::
|
|
278
|
+
# scan_resp = PWN::SDR::GQRX.log_signals(
|
|
279
|
+
# signals_arr: 'required - Array of detected signals',
|
|
280
|
+
# timestamp_start: 'required - Scan start timestamp',
|
|
281
|
+
# scan_log: 'required - Path to save detected signals log'
|
|
282
|
+
# )
|
|
283
|
+
private_class_method def self.log_signals(opts = {})
|
|
284
|
+
signals_arr = opts[:signals_arr]
|
|
285
|
+
timestamp_start = opts[:timestamp_start]
|
|
286
|
+
scan_log = opts[:scan_log]
|
|
287
|
+
|
|
288
|
+
signals = signals_arr.sort_by { |s| s[:freq].to_s.cast_to_raw_hz }
|
|
289
|
+
# Unique signals by frequency
|
|
290
|
+
signals.uniq! { |s| s[:hz] }
|
|
291
|
+
|
|
292
|
+
timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
293
|
+
duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
|
|
294
|
+
# Convert duration seconds to hours minutes seconds
|
|
295
|
+
hours = (duration_secs / 3600).to_i
|
|
296
|
+
minutes = ((duration_secs % 3600) / 60).to_i
|
|
297
|
+
seconds = (duration_secs % 60).to_i
|
|
298
|
+
duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
|
|
299
|
+
|
|
300
|
+
scan_resp = {
|
|
301
|
+
signals: signals,
|
|
302
|
+
total: signals.length,
|
|
303
|
+
timestamp_start: timestamp_start,
|
|
304
|
+
timestamp_end: timestamp_end,
|
|
305
|
+
duration: duration
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
File.write(
|
|
309
|
+
scan_log,
|
|
310
|
+
JSON.pretty_generate(scan_resp)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
scan_resp
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
raise e
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Supported Method Parameters::
|
|
319
|
+
# rds_resp = PWN::SDR::GQRX.decode_rds(
|
|
320
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method'
|
|
321
|
+
# )
|
|
322
|
+
|
|
323
|
+
private_class_method def self.decode_rds(opts = {})
|
|
324
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
325
|
+
|
|
326
|
+
# We toggle RDS off and on to reset the decoder
|
|
327
|
+
rds_resp = gqrx_cmd(
|
|
328
|
+
gqrx_sock: gqrx_sock,
|
|
329
|
+
cmd: 'U RDS 0',
|
|
330
|
+
resp_ok: 'RPRT 0'
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
rds_resp = gqrx_cmd(
|
|
334
|
+
gqrx_sock: gqrx_sock,
|
|
335
|
+
cmd: 'U RDS 1',
|
|
336
|
+
resp_ok: 'RPRT 0'
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
rds_resp = {}
|
|
340
|
+
attempts = 0
|
|
341
|
+
max_attempts = 90
|
|
342
|
+
skip_rds = "\n"
|
|
343
|
+
print 'INFO: Decoding FM radio RDS data (Press ENTER to skip)...'
|
|
344
|
+
max_attempts.times do
|
|
345
|
+
attempts += 1
|
|
346
|
+
rds_resp[:rds_pi] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PI')
|
|
347
|
+
rds_resp[:rds_ps_name] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PS_NAME')
|
|
348
|
+
rds_resp[:rds_radiotext] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_RADIOTEXT')
|
|
349
|
+
|
|
350
|
+
# Break if ENTER key pressed
|
|
351
|
+
# This is useful if no RDS data is available
|
|
352
|
+
# on the current frequency (e.g. false+)
|
|
353
|
+
break if $stdin.ready? && $stdin.read_nonblock(1) == skip_rds
|
|
354
|
+
|
|
355
|
+
break if rds_resp[:rds_pi] != '0000' && !rds_resp[:rds_ps_name].empty? && !rds_resp[:rds_radiotext].empty?
|
|
356
|
+
|
|
357
|
+
print '.'
|
|
358
|
+
sleep 0.1
|
|
359
|
+
end
|
|
360
|
+
puts 'complete.'
|
|
361
|
+
rds_resp
|
|
362
|
+
rescue StandardError => e
|
|
363
|
+
raise e
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Supported Method Parameters::
|
|
367
|
+
# gqrx_sock = PWN::SDR::GQRX.connect(
|
|
368
|
+
# target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
369
|
+
# port: 'optional - GQRX target port (defaults to 7356)'
|
|
370
|
+
# )
|
|
371
|
+
public_class_method def self.connect(opts = {})
|
|
372
|
+
target = opts[:target] ||= '127.0.0.1'
|
|
373
|
+
port = opts[:port] ||= 7356
|
|
374
|
+
|
|
375
|
+
PWN::Plugins::Sock.connect(target: target, port: port)
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
raise e
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Supported Method Parameters::
|
|
381
|
+
# freq_obj = PWN::SDR::GQRX.init_freq(
|
|
174
382
|
# gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
175
383
|
# freq: 'required - Frequency to set',
|
|
176
384
|
# demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
|
|
385
|
+
# rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
|
|
177
386
|
# bandwidth: 'optional - Bandwidth (defaults to 200_000)',
|
|
178
387
|
# squelch: 'optional - Squelch level to set (Defaults to current value)',
|
|
179
388
|
# decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
|
|
180
|
-
# record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to
|
|
181
|
-
#
|
|
182
|
-
#
|
|
389
|
+
# record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
|
|
390
|
+
# suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
|
|
391
|
+
# keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
|
|
183
392
|
# )
|
|
184
393
|
public_class_method def self.init_freq(opts = {})
|
|
185
394
|
gqrx_sock = opts[:gqrx_sock]
|
|
186
395
|
freq = opts[:freq]
|
|
187
|
-
|
|
396
|
+
valid_demodulator_modes = %i[
|
|
397
|
+
AM
|
|
398
|
+
AM_SYNC
|
|
399
|
+
CW
|
|
400
|
+
CWL
|
|
401
|
+
CWU
|
|
402
|
+
FM
|
|
403
|
+
OFF
|
|
404
|
+
LSB
|
|
405
|
+
RAW
|
|
406
|
+
USB
|
|
407
|
+
WFM
|
|
408
|
+
WFM_ST
|
|
409
|
+
WFM_ST_OIRT
|
|
410
|
+
]
|
|
411
|
+
demodulator_mode = opts[:demodulator_mode] ||= :WFM
|
|
412
|
+
raise "ERROR: Invalid demodulator_mode '#{demodulator_mode}'. Valid modes: #{valid_demodulator_modes.join(', ')}" unless valid_demodulator_modes.include?(demodulator_mode.to_sym)
|
|
413
|
+
|
|
414
|
+
rds = opts[:rds] ||= false
|
|
415
|
+
|
|
188
416
|
bandwidth = opts[:bandwidth] ||= 200_000
|
|
189
417
|
squelch = opts[:squelch]
|
|
190
418
|
decoder = opts[:decoder]
|
|
191
|
-
record_dir = opts[:record_dir] ||=
|
|
192
|
-
decoder_opts = opts[:decoder_opts] ||= {}
|
|
419
|
+
record_dir = opts[:record_dir] ||= '/tmp'
|
|
193
420
|
suppress_details = opts[:suppress_details] || false
|
|
421
|
+
keep_alive = opts[:keep_alive] || false
|
|
194
422
|
|
|
195
423
|
raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
|
|
196
424
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if squelch.is_a?(Float) && squelch >= -100.0 && squelch <= 0.0
|
|
425
|
+
unless keep_alive
|
|
426
|
+
squelch = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l SQL').to_f if squelch.nil?
|
|
200
427
|
change_squelch_resp = gqrx_cmd(
|
|
201
428
|
gqrx_sock: gqrx_sock,
|
|
202
429
|
cmd: "L SQL #{squelch}",
|
|
203
430
|
resp_ok: 'RPRT 0'
|
|
204
431
|
)
|
|
432
|
+
|
|
433
|
+
mode_str = demodulator_mode.to_s.upcase
|
|
434
|
+
passband_hz = bandwidth.to_s.cast_to_raw_hz
|
|
435
|
+
gqrx_cmd(
|
|
436
|
+
gqrx_sock: gqrx_sock,
|
|
437
|
+
cmd: "M #{mode_str} #{passband_hz}",
|
|
438
|
+
resp_ok: 'RPRT 0'
|
|
439
|
+
)
|
|
205
440
|
end
|
|
206
441
|
|
|
207
442
|
change_freq_resp = gqrx_cmd(
|
|
208
443
|
gqrx_sock: gqrx_sock,
|
|
209
|
-
cmd: "F #{
|
|
210
|
-
resp_ok: 'RPRT 0'
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
# Set demod mode and bandwidth (always, using defaults if not provided)
|
|
214
|
-
mode_str = demodulator_mode.to_s.upcase
|
|
215
|
-
passband_hz = bandwidth.to_s.raw_hz
|
|
216
|
-
gqrx_cmd(
|
|
217
|
-
gqrx_sock: gqrx_sock,
|
|
218
|
-
cmd: "M #{mode_str} #{passband_hz}",
|
|
444
|
+
cmd: "F #{freq.to_s.cast_to_raw_hz}",
|
|
219
445
|
resp_ok: 'RPRT 0'
|
|
220
446
|
)
|
|
221
447
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
current_freq = gqrx_cmd(
|
|
230
|
-
gqrx_sock: gqrx_sock,
|
|
231
|
-
cmd: 'f'
|
|
232
|
-
)
|
|
448
|
+
current_freq = 0
|
|
449
|
+
while current_freq.to_s.cast_to_raw_hz != freq.to_s.cast_to_raw_hz
|
|
450
|
+
current_freq = gqrx_cmd(
|
|
451
|
+
gqrx_sock: gqrx_sock,
|
|
452
|
+
cmd: 'f'
|
|
453
|
+
)
|
|
454
|
+
end
|
|
233
455
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
456
|
+
freq_obj = {
|
|
457
|
+
bandwidth: bandwidth,
|
|
458
|
+
demodulator_mode: demodulator_mode,
|
|
459
|
+
rds: rds,
|
|
460
|
+
freq: freq
|
|
238
461
|
}
|
|
239
462
|
|
|
240
463
|
unless suppress_details
|
|
464
|
+
demod_n_passband = gqrx_cmd(
|
|
465
|
+
gqrx_sock: gqrx_sock,
|
|
466
|
+
cmd: 'm'
|
|
467
|
+
)
|
|
468
|
+
|
|
241
469
|
audio_gain_db = gqrx_cmd(
|
|
242
470
|
gqrx_sock: gqrx_sock,
|
|
243
471
|
cmd: 'l AF'
|
|
244
472
|
).to_f
|
|
245
473
|
|
|
246
|
-
|
|
247
|
-
gqrx_sock: gqrx_sock,
|
|
248
|
-
cmd: 'l STRENGTH'
|
|
249
|
-
).to_f
|
|
250
|
-
strength_db = strength_db_float.round(1)
|
|
474
|
+
strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
|
|
251
475
|
|
|
252
|
-
|
|
476
|
+
squelch = gqrx_cmd(
|
|
253
477
|
gqrx_sock: gqrx_sock,
|
|
254
478
|
cmd: 'l SQL'
|
|
255
479
|
).to_f
|
|
@@ -269,67 +493,73 @@ module PWN
|
|
|
269
493
|
cmd: 'l BB_GAIN'
|
|
270
494
|
)
|
|
271
495
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
# Resolve decoder module via case statement for extensibility
|
|
285
|
-
case decoder
|
|
286
|
-
when :gsm
|
|
287
|
-
decoder_module = PWN::SDR::Decoder::GSM
|
|
288
|
-
else
|
|
289
|
-
raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
|
|
290
|
-
end
|
|
496
|
+
rds_resp = nil
|
|
497
|
+
rds_resp = decode_rds(gqrx_sock: gqrx_sock) if rds
|
|
498
|
+
|
|
499
|
+
freq_obj[:audio_gain_db] = audio_gain_db
|
|
500
|
+
freq_obj[:demod_mode_n_passband] = demod_n_passband
|
|
501
|
+
freq_obj[:bb_gain] = bb_gain
|
|
502
|
+
freq_obj[:if_gain] = if_gain
|
|
503
|
+
freq_obj[:rf_gain] = rf_gain
|
|
504
|
+
freq_obj[:squelch] = squelch
|
|
505
|
+
freq_obj[:strength_db] = strength_db
|
|
506
|
+
freq_obj[:rds] = rds_resp
|
|
507
|
+
end
|
|
291
508
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
509
|
+
# Start recording and decoding if decoder provided
|
|
510
|
+
decoder_module = nil
|
|
511
|
+
decoder_thread = nil
|
|
512
|
+
record_path = nil
|
|
513
|
+
if decoder
|
|
514
|
+
# Resolve decoder module via case statement for extensibility
|
|
515
|
+
case decoder
|
|
516
|
+
when :gsm
|
|
517
|
+
decoder_module = PWN::SDR::Decoder::GSM
|
|
518
|
+
else
|
|
519
|
+
raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
|
|
520
|
+
end
|
|
295
521
|
|
|
296
|
-
|
|
297
|
-
|
|
522
|
+
# Ensure recording is off before starting
|
|
523
|
+
record_status = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'u RECORD')
|
|
524
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if record_status == '1'
|
|
298
525
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
expected_filename = "gqrx_#{start_time.strftime('%Y%m%d_%H%M%S')}_#{current_freq_raw}.wav"
|
|
302
|
-
record_path = File.join(record_dir, expected_filename)
|
|
526
|
+
# Start recording
|
|
527
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 1', resp_ok: 'RPRT 0')
|
|
303
528
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
frequency: current_freq,
|
|
309
|
-
bandwidth: bandwidth,
|
|
310
|
-
demodulator_mode: demodulator_mode
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
# Initialize and start decoder (module style: .start returns thread)
|
|
314
|
-
decoder_thread = decoder_module.start(
|
|
315
|
-
gqrx_obj: gqrx_obj_partial,
|
|
316
|
-
**decoder_opts
|
|
317
|
-
)
|
|
529
|
+
# Prepare for decoder
|
|
530
|
+
start_time = Time.now
|
|
531
|
+
expected_filename = "gqrx_#{start_time.strftime('%Y%m%d_%H%M%S')}_#{current_freq_raw}.wav"
|
|
532
|
+
record_path = File.join(record_dir, expected_filename)
|
|
318
533
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
534
|
+
# Build partial gqrx_obj for decoder start
|
|
535
|
+
freq_obj[:record_path] = record_path
|
|
536
|
+
|
|
537
|
+
# Initialize and start decoder (module style: .start returns thread)
|
|
538
|
+
freq_obj[:gqrx_sock] = gqrx_sock
|
|
539
|
+
decoder_thread = decoder_module.start(freq_obj: freq_obj)
|
|
540
|
+
freq_obj.delete(:gqrx_sock)
|
|
541
|
+
|
|
542
|
+
freq_obj[:freq] = current_freq
|
|
543
|
+
freq_obj[:decoder] = decoder
|
|
544
|
+
freq_obj[:decoder_module] = decoder_module
|
|
545
|
+
freq_obj[:decoder_thread] = decoder_thread
|
|
546
|
+
freq_obj[:record_path] = record_path
|
|
324
547
|
end
|
|
325
548
|
|
|
326
|
-
|
|
549
|
+
freq_obj
|
|
327
550
|
rescue StandardError => e
|
|
328
551
|
raise e
|
|
329
552
|
ensure
|
|
330
|
-
# Ensure
|
|
331
|
-
|
|
332
|
-
|
|
553
|
+
# Ensure decoder recording stops
|
|
554
|
+
if decoder
|
|
555
|
+
gqrx_cmd(
|
|
556
|
+
gqrx_sock: gqrx_sock,
|
|
557
|
+
cmd: 'U RECORD 0',
|
|
558
|
+
resp_ok: 'RPRT 0'
|
|
559
|
+
)
|
|
560
|
+
decoder_module.stop(freq_obj: freq_obj)
|
|
561
|
+
end
|
|
562
|
+
disconnect(gqrx_sock: gqrx_sock) if gqrx_sock.is_a?(TCPSocket) && !keep_alive
|
|
333
563
|
end
|
|
334
564
|
|
|
335
565
|
# Supported Method Parameters::
|
|
@@ -338,79 +568,124 @@ module PWN
|
|
|
338
568
|
# start_freq: 'required - Start frequency of scan range',
|
|
339
569
|
# target_freq: 'required - Target frequency of scan range',
|
|
340
570
|
# demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
|
|
571
|
+
# rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
|
|
341
572
|
# bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
|
|
342
|
-
# overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
|
|
343
573
|
# precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
|
|
344
|
-
# lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
|
|
345
574
|
# strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
|
|
346
575
|
# squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
|
|
576
|
+
# audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
|
|
577
|
+
# rf_gain: 'optional - RF gain (defaults to 0.0)',
|
|
578
|
+
# intermediate_gain: 'optional - Intermediate gain (defaults to 32.0)',
|
|
579
|
+
# baseband_gain: 'optional - Baseband gain (defaults to 10.0)',
|
|
580
|
+
# scan_log: 'optional - Path to save detected signals log (defaults to /tmp/pwn_sdr_gqrx_scan_<start_freq>-<target_freq>_<timestamp>.json)',
|
|
347
581
|
# location: 'optional - Location string to include in AI analysis (e.g., "New York, NY", 90210, GPS coords, etc.)'
|
|
348
582
|
# )
|
|
349
583
|
|
|
350
584
|
public_class_method def self.scan_range(opts = {})
|
|
585
|
+
timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
586
|
+
log_timestamp = Time.now.strftime('%Y-%m-%d')
|
|
587
|
+
|
|
351
588
|
gqrx_sock = opts[:gqrx_sock]
|
|
589
|
+
|
|
352
590
|
start_freq = opts[:start_freq]
|
|
591
|
+
hz_start = start_freq.to_s.cast_to_raw_hz
|
|
592
|
+
|
|
353
593
|
target_freq = opts[:target_freq]
|
|
594
|
+
hz_target = target_freq.to_s.cast_to_raw_hz
|
|
595
|
+
|
|
354
596
|
demodulator_mode = opts[:demodulator_mode]
|
|
597
|
+
rds = opts[:rds] ||= false
|
|
598
|
+
|
|
355
599
|
bandwidth = opts[:bandwidth] ||= 200_000
|
|
356
|
-
overlap_protection = opts[:overlap_protection] || false
|
|
357
600
|
precision = opts[:precision] ||= 1
|
|
358
|
-
lock_freq_duration = opts[:lock_freq_duration] ||= 0.04
|
|
359
601
|
strength_lock = opts[:strength_lock] ||= -70.0
|
|
360
602
|
squelch = opts[:squelch] ||= (strength_lock - 3.0)
|
|
603
|
+
scan_log = opts[:scan_log] ||= "/tmp/pwn_sdr_gqrx_scan_#{hz_start.to_i.cast_to_pretty_hz}-#{hz_target.to_i.cast_to_pretty_hz}_#{log_timestamp}.json"
|
|
361
604
|
location = opts[:location] ||= 'United States'
|
|
362
605
|
|
|
363
|
-
timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
364
|
-
|
|
365
|
-
hz_start = start_freq.to_s.raw_hz
|
|
366
|
-
hz_target = target_freq.to_s.raw_hz
|
|
367
606
|
step_hz = 10**(precision - 1)
|
|
368
607
|
step = hz_start > hz_target ? -step_hz : step_hz
|
|
369
608
|
|
|
370
|
-
# Set
|
|
609
|
+
# Set squelch once for the scan
|
|
610
|
+
change_squelch_resp = gqrx_cmd(
|
|
611
|
+
gqrx_sock: gqrx_sock,
|
|
612
|
+
cmd: "L SQL #{squelch}",
|
|
613
|
+
resp_ok: 'RPRT 0'
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# We always disable RDS decoding at during the scan
|
|
617
|
+
# to prevent unnecessary processing overhead.
|
|
618
|
+
# We return the rds boolean in the scan_resp object
|
|
619
|
+
# so it will be picked up and used appropriately
|
|
620
|
+
# when calling analyze_scan or analyze_log methods.
|
|
621
|
+
rds_resp = gqrx_cmd(
|
|
622
|
+
gqrx_sock: gqrx_sock,
|
|
623
|
+
cmd: 'U RDS 0',
|
|
624
|
+
resp_ok: 'RPRT 0'
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Set demodulator mode & passband once for the scan
|
|
371
628
|
mode_str = demodulator_mode.to_s.upcase
|
|
372
|
-
passband_hz = bandwidth.to_s.
|
|
629
|
+
passband_hz = bandwidth.to_s.cast_to_raw_hz
|
|
373
630
|
gqrx_cmd(
|
|
374
631
|
gqrx_sock: gqrx_sock,
|
|
375
632
|
cmd: "M #{mode_str} #{passband_hz}",
|
|
376
633
|
resp_ok: 'RPRT 0'
|
|
377
634
|
)
|
|
378
635
|
|
|
379
|
-
|
|
380
|
-
|
|
636
|
+
audio_gain_db = opts[:audio_gain_db] ||= 6.0
|
|
637
|
+
audio_gain_db = audio_gain_db.to_f
|
|
638
|
+
audio_gain_db_resp = gqrx_cmd(
|
|
639
|
+
gqrx_sock: gqrx_sock,
|
|
640
|
+
cmd: "L AF #{audio_gain_db}",
|
|
641
|
+
resp_ok: 'RPRT 0'
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
rf_gain = opts[:rf_gain] ||= 0.0
|
|
645
|
+
rf_gain = rf_gain.to_f
|
|
646
|
+
rf_gain_resp = gqrx_cmd(
|
|
647
|
+
gqrx_sock: gqrx_sock,
|
|
648
|
+
cmd: "L RF_GAIN #{rf_gain}",
|
|
649
|
+
resp_ok: 'RPRT 0'
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
intermediate_gain = opts[:intermediate_gain] ||= 32.0
|
|
653
|
+
intermediate_gain = intermediate_gain.to_f
|
|
654
|
+
intermediate_resp = gqrx_cmd(
|
|
655
|
+
gqrx_sock: gqrx_sock,
|
|
656
|
+
cmd: "L IF_GAIN #{intermediate_gain}",
|
|
657
|
+
resp_ok: 'RPRT 0'
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
baseband_gain = opts[:baseband_gain] ||= 10.0
|
|
661
|
+
baseband_gain = baseband_gain.to_f
|
|
662
|
+
baseband_resp = gqrx_cmd(
|
|
663
|
+
gqrx_sock: gqrx_sock,
|
|
664
|
+
cmd: "L BB_GAIN #{baseband_gain}",
|
|
665
|
+
resp_ok: 'RPRT 0'
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
prev_freq_obj = init_freq(
|
|
381
669
|
gqrx_sock: gqrx_sock,
|
|
382
|
-
freq:
|
|
670
|
+
freq: hz_start,
|
|
383
671
|
demodulator_mode: demodulator_mode,
|
|
672
|
+
rds: rds,
|
|
384
673
|
bandwidth: bandwidth,
|
|
385
674
|
squelch: squelch,
|
|
386
|
-
suppress_details: true
|
|
675
|
+
suppress_details: true,
|
|
676
|
+
keep_alive: true
|
|
387
677
|
)
|
|
388
|
-
prev_freq_hash[:lock_freq_duration] = lock_freq_duration
|
|
389
|
-
prev_freq_hash[:strength_lock] = strength_lock
|
|
390
678
|
|
|
391
|
-
in_signal = false
|
|
392
679
|
candidate_signals = []
|
|
393
|
-
strength_history = []
|
|
394
680
|
|
|
395
|
-
# ──────────────────────────────────────────────────────────────
|
|
396
681
|
# Adaptive peak finder – trims weakest ends after each pass
|
|
397
682
|
# Converges quickly to the true center of the bell curve
|
|
398
|
-
# ──────────────────────────────────────────────────────────────
|
|
399
683
|
find_best_peak = lambda do |opts = {}|
|
|
400
|
-
beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.
|
|
401
|
-
|
|
402
|
-
end_of_signal_hz = top_of_signal_hz + step_hz
|
|
403
|
-
|
|
404
|
-
# current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
|
|
405
|
-
# puts "Current Frequency: #{current_hz.pretty_hz}"
|
|
406
|
-
puts "Signal Began: #{beg_of_signal_hz.pretty_hz}"
|
|
407
|
-
puts "Signal Appeared to Peak at: #{top_of_signal_hz.pretty_hz}"
|
|
408
|
-
puts "Calculated Signal End: #{end_of_signal_hz.pretty_hz}"
|
|
409
|
-
# steps_between_beg_n_end = ((end_of_signal_hz - beg_of_signal_hz) / step_hz).abs
|
|
410
|
-
# puts steps_between_beg_n_end.inspect
|
|
684
|
+
beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.cast_to_raw_hz
|
|
685
|
+
end_of_signal_hz = opts[:end_of_signal_hz].to_s.cast_to_raw_hz
|
|
411
686
|
|
|
412
687
|
samples = []
|
|
413
|
-
prev_best_sample =
|
|
688
|
+
prev_best_sample = {}
|
|
414
689
|
consecutive_best = 0
|
|
415
690
|
direction_up = true
|
|
416
691
|
|
|
@@ -433,19 +708,19 @@ module PWN
|
|
|
433
708
|
print '>' if direction_up
|
|
434
709
|
print '<' unless direction_up
|
|
435
710
|
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
|
|
436
|
-
|
|
437
|
-
strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
|
|
438
|
-
strength_db = strength_db_float.round(1)
|
|
711
|
+
strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
|
|
439
712
|
samples.push({ hz: hz, strength_db: strength_db })
|
|
440
713
|
|
|
441
|
-
# current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.
|
|
442
|
-
# puts "Sampled Frequency: #{current_hz.
|
|
714
|
+
# current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.cast_to_raw_hz
|
|
715
|
+
# puts "Sampled Frequency: #{current_hz.to_i.cast_to_pretty_hz} => Strength: #{strength_db} dBFS"
|
|
443
716
|
end
|
|
444
717
|
|
|
445
718
|
# Compute fresh averaged_samples from all cumulative samples
|
|
446
719
|
averaged_samples = []
|
|
447
720
|
samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
|
|
448
|
-
avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size)
|
|
721
|
+
avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size)
|
|
722
|
+
# avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(2)
|
|
723
|
+
# avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
|
|
449
724
|
averaged_samples.push({ hz: hz, strength_db: avg_strength })
|
|
450
725
|
end
|
|
451
726
|
|
|
@@ -475,7 +750,7 @@ module PWN
|
|
|
475
750
|
best_sample = averaged_samples.max_by { |s| s[:strength_db] }
|
|
476
751
|
|
|
477
752
|
# Check for improvement
|
|
478
|
-
if best_sample == prev_best_sample
|
|
753
|
+
if best_sample[:hz] == prev_best_sample[:hz]
|
|
479
754
|
consecutive_best += 1
|
|
480
755
|
else
|
|
481
756
|
consecutive_best = 0
|
|
@@ -484,7 +759,7 @@ module PWN
|
|
|
484
759
|
# Dup to avoid reference issues
|
|
485
760
|
prev_best_sample = best_sample.dup
|
|
486
761
|
|
|
487
|
-
puts "Pass #{pass_count}: Best #{best_sample[:hz].
|
|
762
|
+
puts "Pass #{pass_count}: Best #{best_sample[:hz].to_i.cast_to_pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
|
|
488
763
|
|
|
489
764
|
# Break if no improvement in 3 consecutive passes or theres only one sample left
|
|
490
765
|
break if consecutive_best.positive? || averaged_samples.size == 1
|
|
@@ -494,91 +769,175 @@ module PWN
|
|
|
494
769
|
end
|
|
495
770
|
|
|
496
771
|
# Begin scanning range
|
|
497
|
-
puts "
|
|
772
|
+
puts "\n"
|
|
773
|
+
puts '-' * 86
|
|
774
|
+
puts "INFO: Scanning from #{hz_start.to_i.cast_to_pretty_hz} to #{hz_target.to_i.cast_to_pretty_hz} in steps of #{step.abs.to_i.cast_to_pretty_hz} Hz."
|
|
775
|
+
puts "If scans are slow and/or you're experiencing false positives/negatives,"
|
|
776
|
+
puts 'consider adjusting the following:'
|
|
777
|
+
puts "1. The SDR's sample rate in GQRX"
|
|
778
|
+
puts "\s\s- Click on `Configure I/O devices`."
|
|
779
|
+
puts "\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000)."
|
|
780
|
+
puts '2. Adjust the :strength_lock parameter.'
|
|
781
|
+
puts '3. Adjust the :precision parameter.'
|
|
782
|
+
puts '4. Disable AI introspection in PWN::Env'
|
|
783
|
+
puts 'Happy scanning!'
|
|
784
|
+
puts '-' * 86
|
|
785
|
+
puts "\n\n\n"
|
|
498
786
|
|
|
499
787
|
signals_arr = []
|
|
500
|
-
|
|
788
|
+
hz = hz_start
|
|
789
|
+
while hz <= hz_target
|
|
501
790
|
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
791
|
+
current_freq = 0
|
|
792
|
+
while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
|
|
793
|
+
current_freq = gqrx_cmd(
|
|
794
|
+
gqrx_sock: gqrx_sock,
|
|
795
|
+
cmd: 'f'
|
|
796
|
+
)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
|
|
800
|
+
|
|
801
|
+
if strength_db >= strength_lock
|
|
802
|
+
puts '-' * 86
|
|
803
|
+
# Find left and right edges of the signal
|
|
804
|
+
candidate_signals = edge_detection(
|
|
805
|
+
gqrx_sock: gqrx_sock,
|
|
806
|
+
hz: hz,
|
|
807
|
+
step_hz: step_hz,
|
|
808
|
+
strength_lock: strength_lock
|
|
809
|
+
)
|
|
810
|
+
elsif candidate_signals.length.positive?
|
|
811
|
+
beg_of_signal_hz = candidate_signals.first[:hz]
|
|
812
|
+
top_of_signal_hz_idx = (candidate_signals.length - 1) / 2
|
|
813
|
+
top_of_signal_hz = candidate_signals[top_of_signal_hz_idx][:hz]
|
|
814
|
+
end_of_signal_hz = candidate_signals.last[:hz]
|
|
815
|
+
puts 'Candidate Signal(s) Detected:'
|
|
816
|
+
puts JSON.pretty_generate(candidate_signals)
|
|
817
|
+
|
|
818
|
+
prev_freq = prev_freq_obj[:freq].to_s.cast_to_raw_hz
|
|
819
|
+
distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_freq).abs
|
|
820
|
+
half_bandwidth = (bandwidth / 2).to_i
|
|
821
|
+
|
|
822
|
+
puts "Key Frequencies: Begin: #{beg_of_signal_hz.to_i.cast_to_pretty_hz} Hz | Estimated Top: #{top_of_signal_hz.to_i.cast_to_pretty_hz} Hz | End: #{end_of_signal_hz.to_i.cast_to_pretty_hz} Hz"
|
|
823
|
+
|
|
824
|
+
puts 'Finding Best Peak...'
|
|
825
|
+
best_peak = find_best_peak.call(
|
|
826
|
+
beg_of_signal_hz: beg_of_signal_hz,
|
|
827
|
+
end_of_signal_hz: end_of_signal_hz
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
if best_peak[:hz] && best_peak[:strength_db] > strength_lock
|
|
831
|
+
puts "\n**** Detected Signal ****"
|
|
832
|
+
best_freq = best_peak[:hz].to_i.cast_to_pretty_hz
|
|
833
|
+
best_strength_db = best_peak[:strength_db]
|
|
834
|
+
prev_freq_obj = init_freq(
|
|
835
|
+
gqrx_sock: gqrx_sock,
|
|
836
|
+
freq: best_freq,
|
|
837
|
+
rds: rds,
|
|
838
|
+
suppress_details: true,
|
|
839
|
+
keep_alive: true
|
|
840
|
+
)
|
|
841
|
+
prev_freq_obj[:strength_lock] = strength_lock
|
|
842
|
+
prev_freq_obj[:strength_db] = best_strength_db
|
|
843
|
+
|
|
844
|
+
system_role_content = "Analyze signal data captured by a software-defined-radio using GQRX at the following location: #{location}. Respond with just FCC information about the transmission if available. If the frequency is unlicensed or not found in FCC records, state that clearly. Be clear and concise in your analysis."
|
|
845
|
+
ai_analysis = PWN::AI::Introspection.reflect_on(
|
|
846
|
+
request: prev_freq_obj.to_json,
|
|
847
|
+
system_role_content: system_role_content,
|
|
848
|
+
suppress_pii_warning: true
|
|
535
849
|
)
|
|
536
850
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
system_role_content = "Analyze signal data captured by a software-defined-radio using GQRX at the following location: #{location}. Respond with just FCC information about the transmission if available. If the frequency is unlicensed or not found in FCC records, state that clearly. Be clear and concise in your analysis."
|
|
550
|
-
ai_analysis = PWN::AI::Introspection.reflect_on(
|
|
551
|
-
request: prev_freq_hash.to_json,
|
|
552
|
-
system_role_content: system_role_content,
|
|
553
|
-
suppress_pii_warning: true
|
|
554
|
-
)
|
|
555
|
-
prev_freq_hash[:ai_analysis] = ai_analysis unless ai_analysis.nil?
|
|
556
|
-
puts "\n**** Detected Signal ****"
|
|
557
|
-
puts JSON.pretty_generate(prev_freq_hash)
|
|
558
|
-
signals_arr.push(prev_freq_hash)
|
|
559
|
-
end
|
|
560
|
-
candidate_signals.clear
|
|
561
|
-
sleep lock_freq_duration
|
|
851
|
+
prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
|
|
852
|
+
puts JSON.pretty_generate(prev_freq_obj)
|
|
853
|
+
puts '-' * 86
|
|
854
|
+
puts "\n\n\n"
|
|
855
|
+
signals_arr.push(prev_freq_obj)
|
|
856
|
+
log_signals(
|
|
857
|
+
signals_arr: signals_arr,
|
|
858
|
+
timestamp_start: timestamp_start,
|
|
859
|
+
scan_log: scan_log
|
|
860
|
+
)
|
|
861
|
+
hz = end_of_signal_hz
|
|
862
|
+
# gets
|
|
562
863
|
end
|
|
563
|
-
|
|
564
|
-
strength_history = []
|
|
864
|
+
candidate_signals.clear
|
|
565
865
|
end
|
|
866
|
+
hz += step_hz
|
|
566
867
|
end
|
|
567
|
-
signals = signals_arr.sort_by { |s| s[:frequency].to_s.raw_hz }
|
|
568
|
-
timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
569
|
-
duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
|
|
570
|
-
# Convert duration seconds to hours minutes seconds
|
|
571
|
-
hours = (duration_secs / 3600).to_i
|
|
572
|
-
minutes = ((duration_secs % 3600) / 60).to_i
|
|
573
|
-
seconds = (duration_secs % 60).to_i
|
|
574
|
-
duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
|
|
575
868
|
|
|
576
|
-
|
|
577
|
-
|
|
869
|
+
log_signals(
|
|
870
|
+
signals_arr: signals_arr,
|
|
578
871
|
timestamp_start: timestamp_start,
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
872
|
+
scan_log: scan_log
|
|
873
|
+
)
|
|
874
|
+
rescue Interrupt
|
|
875
|
+
puts "\nCTRL+C detected - goodbye."
|
|
876
|
+
rescue StandardError => e
|
|
877
|
+
raise e
|
|
878
|
+
ensure
|
|
879
|
+
disconnect(gqrx_sock: gqrx_sock)
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# Supported Method Parameters::
|
|
883
|
+
# PWN::SDR::GQRX.analyze_scan(
|
|
884
|
+
# scan_resp: 'required - Scan response hash returned from #scan_range method',
|
|
885
|
+
# target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
886
|
+
# port: 'optional - GQRX target port (defaults to 7356)'
|
|
887
|
+
# )
|
|
888
|
+
public_class_method def self.analyze_scan(opts = {})
|
|
889
|
+
scan_resp = opts[:scan_resp]
|
|
890
|
+
raise 'ERROR: scan_resp is required.' if scan_resp.nil? || scan_resp[:signals].nil? || scan_resp[:signals].empty?
|
|
891
|
+
|
|
892
|
+
target = opts[:target]
|
|
893
|
+
port = opts[:port]
|
|
894
|
+
gqrx_sock = connect(
|
|
895
|
+
target: target,
|
|
896
|
+
port: port
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
scan_resp[:signals].each do |signal|
|
|
900
|
+
signal[:gqrx_sock] = gqrx_sock
|
|
901
|
+
# This is required to keep connection alive during analysis
|
|
902
|
+
signal[:keep_alive] = true
|
|
903
|
+
freq_obj = init_freq(signal)
|
|
904
|
+
freq_obj = signal.merge(freq_obj)
|
|
905
|
+
# Redact gqrx_sock from output
|
|
906
|
+
freq_obj.delete(:gqrx_sock)
|
|
907
|
+
puts JSON.pretty_generate(freq_obj)
|
|
908
|
+
print 'Press [ENTER] to continue...'
|
|
909
|
+
gets
|
|
910
|
+
puts "\n" * 3
|
|
911
|
+
end
|
|
912
|
+
rescue Interrupt
|
|
913
|
+
puts "\nCTRL+C detected - goodbye."
|
|
914
|
+
rescue StandardError => e
|
|
915
|
+
raise e
|
|
916
|
+
ensure
|
|
917
|
+
disconnect(gqrx_sock: gqrx_sock)
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# Supported Method Parameters::
|
|
921
|
+
# PWN::SDR::GQRX.analyze_log(
|
|
922
|
+
# scan_log: 'required - Path to signals log file',
|
|
923
|
+
# target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
924
|
+
# port: 'optional - GQRX target port (defaults to 7356)'
|
|
925
|
+
# )
|
|
926
|
+
public_class_method def self.analyze_log(opts = {})
|
|
927
|
+
scan_log = opts[:scan_log]
|
|
928
|
+
raise 'ERROR: scan_log path is required.' unless File.exist?(scan_log)
|
|
929
|
+
|
|
930
|
+
scan_resp = JSON.parse(File.read(scan_log), symbolize_names: true)
|
|
931
|
+
raise 'ERROR: No signals found in log.' if scan_resp[:signals].nil? || scan_resp[:signals].empty?
|
|
932
|
+
|
|
933
|
+
target = opts[:target]
|
|
934
|
+
port = opts[:port]
|
|
935
|
+
|
|
936
|
+
analyze_scan(
|
|
937
|
+
scan_resp: scan_resp,
|
|
938
|
+
target: target,
|
|
939
|
+
port: port
|
|
940
|
+
)
|
|
582
941
|
rescue StandardError => e
|
|
583
942
|
raise e
|
|
584
943
|
end
|
|
@@ -590,7 +949,7 @@ module PWN
|
|
|
590
949
|
public_class_method def self.disconnect(opts = {})
|
|
591
950
|
gqrx_sock = opts[:gqrx_sock]
|
|
592
951
|
|
|
593
|
-
PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock)
|
|
952
|
+
PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock) unless gqrx_sock.closed?
|
|
594
953
|
rescue StandardError => e
|
|
595
954
|
raise e
|
|
596
955
|
end
|
|
@@ -608,25 +967,20 @@ module PWN
|
|
|
608
967
|
public_class_method def self.help
|
|
609
968
|
puts "USAGE:
|
|
610
969
|
gqrx_sock = #{self}.connect(
|
|
611
|
-
|
|
970
|
+
target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
612
971
|
port: 'optional - GQRX target port (defaults to 7356)'
|
|
613
972
|
)
|
|
614
973
|
|
|
615
|
-
|
|
616
|
-
gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
617
|
-
cmd: 'required - GQRX command to execute',
|
|
618
|
-
resp_ok: 'optional - Expected response from GQRX to indicate success'
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
init_freq_hash = #{self}.init_freq(
|
|
974
|
+
freq_obj = #{self}.init_freq(
|
|
622
975
|
gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
623
976
|
freq: 'required - Frequency to set',
|
|
624
977
|
demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
|
|
978
|
+
rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
|
|
625
979
|
bandwidth: 'optional - Bandwidth (defaults to 200_000)',
|
|
626
980
|
decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
|
|
627
|
-
record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to
|
|
628
|
-
|
|
629
|
-
|
|
981
|
+
record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
|
|
982
|
+
suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
|
|
983
|
+
keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
|
|
630
984
|
)
|
|
631
985
|
|
|
632
986
|
scan_resp = #{self}.scan_range(
|
|
@@ -635,14 +989,29 @@ module PWN
|
|
|
635
989
|
target_freq: 'required - Target frequency',
|
|
636
990
|
demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
|
|
637
991
|
bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
|
|
638
|
-
overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
|
|
639
992
|
precision: 'optional - Precision (Defaults to 1)',
|
|
640
|
-
lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
|
|
641
993
|
strength_lock: 'optional - Strength lock (defaults to -70.0)',
|
|
642
994
|
squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
|
|
995
|
+
audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
|
|
996
|
+
rf_gain: 'optional - RF gain (defaults to 0.0)',
|
|
997
|
+
intermediate_gain: 'optional - Intermediate gain (defaults to 32.0)',
|
|
998
|
+
baseband_gain: 'optional - Baseband gain (defaults to 10.0)',
|
|
999
|
+
scan_log: 'optional - Path to save detected signals log (defaults to /tmp/pwn_sdr_gqrx_scan_<start_freq>-<target_freq>_<timestamp>.json)',
|
|
643
1000
|
location: 'optional - Location string to include in AI analysis (e.g., \"New York, NY\", 90210, GPS coords, etc.)'
|
|
644
1001
|
)
|
|
645
1002
|
|
|
1003
|
+
#{self}.analyze_scan(
|
|
1004
|
+
scan_resp: 'required - Scan response object from #scan_range method',
|
|
1005
|
+
target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
1006
|
+
port: 'optional - GQRX target port (defaults to 7356)'
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
#{self}.analyze_log(
|
|
1010
|
+
scan_log: 'required - Path to signals log file',
|
|
1011
|
+
target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
1012
|
+
port: 'optional - GQRX target port (defaults to 7356)'
|
|
1013
|
+
)
|
|
1014
|
+
|
|
646
1015
|
#{self}.disconnect(
|
|
647
1016
|
gqrx_sock: 'required - GQRX socket object returned from #connect method'
|
|
648
1017
|
)
|