pwn 0.5.512 → 0.5.514

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/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 raw_hz
12
+ def cast_to_raw_hz
13
13
  gsub('.', '').to_i
14
14
  end
15
15
  end
@@ -20,67 +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 pretty_hz
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
- # scan_resp = PWN::SDR::GQRX.log_signals(
35
- # signals_arr: 'required - Array of detected signals',
36
- # timestamp_start: 'required - Scan start timestamp',
37
- # scan_log: 'required - Path to save detected signals log'
38
- # )
39
- private_class_method def self.log_signals(opts = {})
40
- signals_arr = opts[:signals_arr]
41
- timestamp_start = opts[:timestamp_start]
42
- scan_log = opts[:scan_log]
43
-
44
- signals = signals_arr.sort_by { |s| s[:freq].to_s.raw_hz }
45
- timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
46
- duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
47
- # Convert duration seconds to hours minutes seconds
48
- hours = (duration_secs / 3600).to_i
49
- minutes = ((duration_secs % 3600) / 60).to_i
50
- seconds = (duration_secs % 60).to_i
51
- duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
52
-
53
- scan_resp = {
54
- signals: signals,
55
- timestamp_start: timestamp_start,
56
- timestamp_end: timestamp_end,
57
- duration: duration
58
- }
59
-
60
- File.write(
61
- scan_log,
62
- JSON.pretty_generate(scan_resp)
63
- )
64
-
65
- scan_resp
66
- rescue StandardError => e
67
- raise e
68
- end
69
-
70
- # Supported Method Parameters::
71
- # gqrx_sock = PWN::SDR::GQRX.connect(
72
- # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
73
- # port: 'optional - GQRX target port (defaults to 7356)'
74
- # )
75
- public_class_method def self.connect(opts = {})
76
- target = opts[:target] ||= '127.0.0.1'
77
- port = opts[:port] ||= 7356
78
-
79
- PWN::Plugins::Sock.connect(target: target, port: port)
80
- rescue StandardError => e
81
- raise e
82
- end
83
-
84
33
  # Supported Method Parameters::
85
34
  # gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
86
35
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
@@ -88,17 +37,22 @@ module PWN
88
37
  # resp_ok: 'optional - Expected response from GQRX to indicate success'
89
38
  # )
90
39
 
91
- public_class_method def self.gqrx_cmd(opts = {})
40
+ private_class_method def self.gqrx_cmd(opts = {})
92
41
  gqrx_sock = opts[:gqrx_sock]
93
42
  cmd = opts[:cmd]
94
43
  resp_ok = opts[:resp_ok]
95
44
 
96
45
  # Most Recent GQRX Command Set:
97
46
  # https://raw.githubusercontent.com/gqrx-sdr/gqrx/master/resources/remote-control.txt
47
+ # Remote control protocol.
48
+ #
98
49
  # Supported commands:
99
- # f Get frequency [Hz]
100
- # F <frequency> Set frequency [Hz]
101
- # m Get demodulator mode and passband
50
+ # f
51
+ # Get frequency [Hz]
52
+ # F <frequency>
53
+ # Set frequency [Hz]
54
+ # m
55
+ # Get demodulator mode and passband
102
56
  # M <mode> [passband]
103
57
  # Set demodulator mode and passband [Hz]
104
58
  # Passing a '?' as the first argument instead of 'mode' will return
@@ -121,18 +75,30 @@ module PWN
121
75
  # Set the value of the gain setting with the name <gain_name> to <value>
122
76
  # p RDS_PI
123
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
124
82
  # u RECORD
125
83
  # Get status of audio recorder
126
84
  # U RECORD <status>
127
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>
128
90
  # u DSP
129
91
  # Get DSP (SDR receiver) status
130
92
  # U DSP <status>
131
93
  # Set DSP (SDR receiver) status to <status>
132
94
  # u RDS
133
- # Get RDS decoder to <status>. Only functions in WFM mode.
95
+ # Get RDS decoder status. Only functions in WFM mode.
134
96
  # U RDS <status>
135
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>
136
102
  # q|Q
137
103
  # Close connection
138
104
  # AOS
@@ -159,6 +125,7 @@ module PWN
159
125
  # _
160
126
  # Get version
161
127
  #
128
+ #
162
129
  # Reply:
163
130
  # RPRT 0
164
131
  # Command successful
@@ -191,7 +158,7 @@ module PWN
191
158
  raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
192
159
 
193
160
  # Reformat positive integer frequency responses (e.g., from 'f')
194
- response_str = response_str.to_i.pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
161
+ response_str = response_str.to_i.cast_to_pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
195
162
 
196
163
  response_str
197
164
  rescue RuntimeError => e
@@ -206,11 +173,218 @@ module PWN
206
173
  raise e
207
174
  end
208
175
 
176
+ # Supported Method Parameters::
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
+ print '$'
189
+ break if strength_db <= prev_strength_db
190
+
191
+ prev_strength_db = strength_db
192
+ sleep 0.0001
193
+ end
194
+
195
+ strength_db
196
+ rescue StandardError => e
197
+ raise e
198
+ end
199
+
200
+ # Supported Method Parameters::
201
+ # candidate_signals = PWN::SDR::GQRX.edge_detection(
202
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
203
+ # hz: 'required - Frequency to start edge detection from',
204
+ # step_hz: 'required - Frequency step in Hz for edge detection',
205
+ # strength_lock: 'required - Strength lock in dBFS to determine signal edges'
206
+ # )
207
+ private_class_method def self.edge_detection(opts = {})
208
+ gqrx_sock = opts[:gqrx_sock]
209
+ hz = opts[:hz]
210
+ step_hz = opts[:step_hz]
211
+ strength_lock = opts[:strength_lock]
212
+ left_candidate_signals = []
213
+ right_candidate_signals = []
214
+ candidate_signals = []
215
+
216
+ # left_candidate_signals.clear
217
+ original_hz = hz
218
+ strength_db = 99.9
219
+ puts 'Finding Beginning Edge of Signal...'
220
+ while strength_db >= strength_lock
221
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
222
+ current_freq = 0
223
+ while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
224
+ current_freq = gqrx_cmd(
225
+ gqrx_sock: gqrx_sock,
226
+ cmd: 'f'
227
+ )
228
+ end
229
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
230
+ candidate = {
231
+ hz: hz.to_s.cast_to_raw_hz,
232
+ freq: hz.to_i.cast_to_pretty_hz,
233
+ strength: strength_db,
234
+ side: :left
235
+ }
236
+ left_candidate_signals.push(candidate)
237
+ hz -= step_hz
238
+ end
239
+ left_candidate_signals.uniq! { |s| s[:hz] }
240
+ left_candidate_signals.sort_by! { |s| s[:hz] }
241
+
242
+ # Now scan forwards to find the end of the signal
243
+ # The end of the signal is where the strength drops below strength_lock
244
+ # right_candidate_signals.clear
245
+ hz = original_hz
246
+
247
+ strength_db = 99.9
248
+ puts 'Finding Ending Edge of Signal...'
249
+ while strength_db >= strength_lock
250
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
251
+ current_freq = 0
252
+ while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
253
+ current_freq = gqrx_cmd(
254
+ gqrx_sock: gqrx_sock,
255
+ cmd: 'f'
256
+ )
257
+ end
258
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
259
+ candidate = {
260
+ hz: hz.to_s.cast_to_raw_hz,
261
+ freq: hz.to_i.cast_to_pretty_hz,
262
+ strength: strength_db,
263
+ side: :right
264
+ }
265
+ right_candidate_signals.push(candidate)
266
+ hz += step_hz
267
+ end
268
+ # Update candidate signals to remove duplicates and sort by hz
269
+ right_candidate_signals.uniq! { |s| s[:hz] }
270
+ right_candidate_signals.sort_by! { |s| s[:hz] }
271
+
272
+ candidate_signals = left_candidate_signals + right_candidate_signals
273
+ candidate_signals.uniq! { |s| s[:hz] }
274
+ candidate_signals.sort_by! { |s| s[:hz] }
275
+ rescue StandardError => e
276
+ raise e
277
+ end
278
+
279
+ # Supported Method Parameters::
280
+ # scan_resp = PWN::SDR::GQRX.log_signals(
281
+ # signals_arr: 'required - Array of detected signals',
282
+ # timestamp_start: 'required - Scan start timestamp',
283
+ # scan_log: 'required - Path to save detected signals log'
284
+ # )
285
+ private_class_method def self.log_signals(opts = {})
286
+ signals_arr = opts[:signals_arr]
287
+ timestamp_start = opts[:timestamp_start]
288
+ scan_log = opts[:scan_log]
289
+
290
+ signals = signals_arr.sort_by { |s| s[:freq].to_s.cast_to_raw_hz }
291
+ # Unique signals by frequency
292
+ signals.uniq! { |s| s[:freq].to_s.cast_to_raw_hz }
293
+
294
+ timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
295
+ duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
296
+ # Convert duration seconds to hours minutes seconds
297
+ hours = (duration_secs / 3600).to_i
298
+ minutes = ((duration_secs % 3600) / 60).to_i
299
+ seconds = (duration_secs % 60).to_i
300
+ duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
301
+
302
+ scan_resp = {
303
+ signals: signals,
304
+ total: signals.length,
305
+ timestamp_start: timestamp_start,
306
+ timestamp_end: timestamp_end,
307
+ duration: duration
308
+ }
309
+
310
+ File.write(
311
+ scan_log,
312
+ JSON.pretty_generate(scan_resp)
313
+ )
314
+
315
+ scan_resp
316
+ rescue StandardError => e
317
+ raise e
318
+ end
319
+
320
+ # Supported Method Parameters::
321
+ # rds_resp = PWN::SDR::GQRX.decode_rds(
322
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method'
323
+ # )
324
+
325
+ private_class_method def self.decode_rds(opts = {})
326
+ gqrx_sock = opts[:gqrx_sock]
327
+
328
+ # We toggle RDS off and on to reset the decoder
329
+ rds_resp = gqrx_cmd(
330
+ gqrx_sock: gqrx_sock,
331
+ cmd: 'U RDS 0',
332
+ resp_ok: 'RPRT 0'
333
+ )
334
+
335
+ rds_resp = gqrx_cmd(
336
+ gqrx_sock: gqrx_sock,
337
+ cmd: 'U RDS 1',
338
+ resp_ok: 'RPRT 0'
339
+ )
340
+
341
+ rds_resp = {}
342
+ attempts = 0
343
+ max_attempts = 90
344
+ skip_rds = "\n"
345
+ print 'INFO: Decoding FM radio RDS data (Press ENTER to skip)...'
346
+ max_attempts.times do
347
+ attempts += 1
348
+ rds_resp[:rds_pi] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PI')
349
+ rds_resp[:rds_ps_name] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PS_NAME')
350
+ rds_resp[:rds_radiotext] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_RADIOTEXT')
351
+
352
+ # Break if ENTER key pressed
353
+ # This is useful if no RDS data is available
354
+ # on the current frequency (e.g. false+)
355
+ break if $stdin.ready? && $stdin.read_nonblock(1) == skip_rds
356
+
357
+ break if rds_resp[:rds_pi] != '0000' && !rds_resp[:rds_ps_name].empty? && !rds_resp[:rds_radiotext].empty?
358
+
359
+ print '.'
360
+ sleep 0.1
361
+ end
362
+ puts 'complete.'
363
+ rds_resp
364
+ rescue StandardError => e
365
+ raise e
366
+ end
367
+
368
+ # Supported Method Parameters::
369
+ # gqrx_sock = PWN::SDR::GQRX.connect(
370
+ # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
371
+ # port: 'optional - GQRX target port (defaults to 7356)'
372
+ # )
373
+ public_class_method def self.connect(opts = {})
374
+ target = opts[:target] ||= '127.0.0.1'
375
+ port = opts[:port] ||= 7356
376
+
377
+ PWN::Plugins::Sock.connect(target: target, port: port)
378
+ rescue StandardError => e
379
+ raise e
380
+ end
381
+
209
382
  # Supported Method Parameters::
210
383
  # freq_obj = PWN::SDR::GQRX.init_freq(
211
384
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
212
385
  # freq: 'required - Frequency to set',
213
386
  # demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
387
+ # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
214
388
  # bandwidth: 'optional - Bandwidth (defaults to 200_000)',
215
389
  # squelch: 'optional - Squelch level to set (Defaults to current value)',
216
390
  # decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
@@ -239,6 +413,8 @@ module PWN
239
413
  demodulator_mode = opts[:demodulator_mode] ||= :WFM
240
414
  raise "ERROR: Invalid demodulator_mode '#{demodulator_mode}'. Valid modes: #{valid_demodulator_modes.join(', ')}" unless valid_demodulator_modes.include?(demodulator_mode.to_sym)
241
415
 
416
+ rds = opts[:rds] ||= false
417
+
242
418
  bandwidth = opts[:bandwidth] ||= 200_000
243
419
  squelch = opts[:squelch]
244
420
  decoder = opts[:decoder]
@@ -248,24 +424,16 @@ module PWN
248
424
 
249
425
  raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
250
426
 
251
- if squelch.is_a?(Float) && squelch >= -100.0 && squelch <= 0.0 && !keep_alive
427
+ unless keep_alive
428
+ squelch = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l SQL').to_f if squelch.nil?
252
429
  change_squelch_resp = gqrx_cmd(
253
430
  gqrx_sock: gqrx_sock,
254
431
  cmd: "L SQL #{squelch}",
255
432
  resp_ok: 'RPRT 0'
256
433
  )
257
- end
258
-
259
- change_freq_resp = gqrx_cmd(
260
- gqrx_sock: gqrx_sock,
261
- cmd: "F #{freq.to_s.raw_hz}",
262
- resp_ok: 'RPRT 0'
263
- )
264
434
 
265
- # Set demod mode and bandwidth (always, using defaults if not provided)
266
- unless keep_alive
267
435
  mode_str = demodulator_mode.to_s.upcase
268
- passband_hz = bandwidth.to_s.raw_hz
436
+ passband_hz = bandwidth.to_s.cast_to_raw_hz
269
437
  gqrx_cmd(
270
438
  gqrx_sock: gqrx_sock,
271
439
  cmd: "M #{mode_str} #{passband_hz}",
@@ -273,38 +441,41 @@ module PWN
273
441
  )
274
442
  end
275
443
 
276
- # Get demodulator mode n passband
277
- demod_n_passband = gqrx_cmd(
444
+ change_freq_resp = gqrx_cmd(
278
445
  gqrx_sock: gqrx_sock,
279
- cmd: 'm'
446
+ cmd: "F #{freq.to_s.cast_to_raw_hz}",
447
+ resp_ok: 'RPRT 0'
280
448
  )
281
449
 
282
- # Get current frequency
283
- current_freq = gqrx_cmd(
284
- gqrx_sock: gqrx_sock,
285
- cmd: 'f'
286
- )
450
+ current_freq = 0
451
+ while current_freq.to_s.cast_to_raw_hz != freq.to_s.cast_to_raw_hz
452
+ current_freq = gqrx_cmd(
453
+ gqrx_sock: gqrx_sock,
454
+ cmd: 'f'
455
+ )
456
+ end
287
457
 
288
458
  freq_obj = {
459
+ bandwidth: bandwidth,
289
460
  demodulator_mode: demodulator_mode,
290
- demod_mode_n_passband: demod_n_passband,
291
- freq: current_freq,
292
- bandwidth: bandwidth
461
+ rds: rds,
462
+ freq: freq
293
463
  }
294
464
 
295
465
  unless suppress_details
466
+ demod_n_passband = gqrx_cmd(
467
+ gqrx_sock: gqrx_sock,
468
+ cmd: 'm'
469
+ )
470
+
296
471
  audio_gain_db = gqrx_cmd(
297
472
  gqrx_sock: gqrx_sock,
298
473
  cmd: 'l AF'
299
474
  ).to_f
300
475
 
301
- strength_db_float = gqrx_cmd(
302
- gqrx_sock: gqrx_sock,
303
- cmd: 'l STRENGTH'
304
- ).to_f
305
- strength_db = strength_db_float.round(1)
476
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
306
477
 
307
- current_squelch = gqrx_cmd(
478
+ squelch = gqrx_cmd(
308
479
  gqrx_sock: gqrx_sock,
309
480
  cmd: 'l SQL'
310
481
  ).to_f
@@ -324,12 +495,17 @@ module PWN
324
495
  cmd: 'l BB_GAIN'
325
496
  )
326
497
 
498
+ rds_resp = nil
499
+ rds_resp = decode_rds(gqrx_sock: gqrx_sock) if rds
500
+
327
501
  freq_obj[:audio_gain_db] = audio_gain_db
328
- freq_obj[:squelch_set] = current_squelch
329
- freq_obj[:rf_gain] = rf_gain
330
- freq_obj[:if_gain] = if_gain
502
+ freq_obj[:demod_mode_n_passband] = demod_n_passband
331
503
  freq_obj[:bb_gain] = bb_gain
504
+ freq_obj[:if_gain] = if_gain
505
+ freq_obj[:rf_gain] = rf_gain
506
+ freq_obj[:squelch] = squelch
332
507
  freq_obj[:strength_db] = strength_db
508
+ freq_obj[:rds] = rds_resp
333
509
  end
334
510
 
335
511
  # Start recording and decoding if decoder provided
@@ -365,6 +541,7 @@ module PWN
365
541
  decoder_thread = decoder_module.start(freq_obj: freq_obj)
366
542
  freq_obj.delete(:gqrx_sock)
367
543
 
544
+ freq_obj[:freq] = current_freq
368
545
  freq_obj[:decoder] = decoder
369
546
  freq_obj[:decoder_module] = decoder_module
370
547
  freq_obj[:decoder_thread] = decoder_thread
@@ -393,10 +570,9 @@ module PWN
393
570
  # start_freq: 'required - Start frequency of scan range',
394
571
  # target_freq: 'required - Target frequency of scan range',
395
572
  # demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
573
+ # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
396
574
  # bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
397
- # overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
398
575
  # precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
399
- # lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
400
576
  # strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
401
577
  # squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
402
578
  # audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
@@ -414,19 +590,19 @@ module PWN
414
590
  gqrx_sock = opts[:gqrx_sock]
415
591
 
416
592
  start_freq = opts[:start_freq]
417
- hz_start = start_freq.to_s.raw_hz
593
+ hz_start = start_freq.to_s.cast_to_raw_hz
418
594
 
419
595
  target_freq = opts[:target_freq]
420
- hz_target = target_freq.to_s.raw_hz
596
+ hz_target = target_freq.to_s.cast_to_raw_hz
421
597
 
422
598
  demodulator_mode = opts[:demodulator_mode]
599
+ rds = opts[:rds] ||= false
600
+
423
601
  bandwidth = opts[:bandwidth] ||= 200_000
424
- overlap_protection = opts[:overlap_protection] || false
425
602
  precision = opts[:precision] ||= 1
426
- lock_freq_duration = opts[:lock_freq_duration] ||= 0.04
427
603
  strength_lock = opts[:strength_lock] ||= -70.0
428
604
  squelch = opts[:squelch] ||= (strength_lock - 3.0)
429
- scan_log = opts[:scan_log] ||= "/tmp/pwn_sdr_gqrx_scan_#{hz_start.pretty_hz}-#{hz_target.pretty_hz}_#{log_timestamp}.json"
605
+ 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"
430
606
  location = opts[:location] ||= 'United States'
431
607
 
432
608
  step_hz = 10**(precision - 1)
@@ -439,9 +615,20 @@ module PWN
439
615
  resp_ok: 'RPRT 0'
440
616
  )
441
617
 
618
+ # We always disable RDS decoding at during the scan
619
+ # to prevent unnecessary processing overhead.
620
+ # We return the rds boolean in the scan_resp object
621
+ # so it will be picked up and used appropriately
622
+ # when calling analyze_scan or analyze_log methods.
623
+ rds_resp = gqrx_cmd(
624
+ gqrx_sock: gqrx_sock,
625
+ cmd: 'U RDS 0',
626
+ resp_ok: 'RPRT 0'
627
+ )
628
+
442
629
  # Set demodulator mode & passband once for the scan
443
630
  mode_str = demodulator_mode.to_s.upcase
444
- passband_hz = bandwidth.to_s.raw_hz
631
+ passband_hz = bandwidth.to_s.cast_to_raw_hz
445
632
  gqrx_cmd(
446
633
  gqrx_sock: gqrx_sock,
447
634
  cmd: "M #{mode_str} #{passband_hz}",
@@ -450,7 +637,7 @@ module PWN
450
637
 
451
638
  audio_gain_db = opts[:audio_gain_db] ||= 6.0
452
639
  audio_gain_db = audio_gain_db.to_f
453
- audio_gain_db_resp = PWN::SDR::GQRX.gqrx_cmd(
640
+ audio_gain_db_resp = gqrx_cmd(
454
641
  gqrx_sock: gqrx_sock,
455
642
  cmd: "L AF #{audio_gain_db}",
456
643
  resp_ok: 'RPRT 0'
@@ -458,7 +645,7 @@ module PWN
458
645
 
459
646
  rf_gain = opts[:rf_gain] ||= 0.0
460
647
  rf_gain = rf_gain.to_f
461
- rf_gain_resp = PWN::SDR::GQRX.gqrx_cmd(
648
+ rf_gain_resp = gqrx_cmd(
462
649
  gqrx_sock: gqrx_sock,
463
650
  cmd: "L RF_GAIN #{rf_gain}",
464
651
  resp_ok: 'RPRT 0'
@@ -466,7 +653,7 @@ module PWN
466
653
 
467
654
  intermediate_gain = opts[:intermediate_gain] ||= 32.0
468
655
  intermediate_gain = intermediate_gain.to_f
469
- intermediate_resp = PWN::SDR::GQRX.gqrx_cmd(
656
+ intermediate_resp = gqrx_cmd(
470
657
  gqrx_sock: gqrx_sock,
471
658
  cmd: "L IF_GAIN #{intermediate_gain}",
472
659
  resp_ok: 'RPRT 0'
@@ -474,35 +661,33 @@ module PWN
474
661
 
475
662
  baseband_gain = opts[:baseband_gain] ||= 10.0
476
663
  baseband_gain = baseband_gain.to_f
477
- baseband_resp = PWN::SDR::GQRX.gqrx_cmd(
664
+ baseband_resp = gqrx_cmd(
478
665
  gqrx_sock: gqrx_sock,
479
666
  cmd: "L BB_GAIN #{baseband_gain}",
480
667
  resp_ok: 'RPRT 0'
481
668
  )
482
669
 
483
- prev_freq_obj = {}
670
+ prev_freq_obj = init_freq(
671
+ gqrx_sock: gqrx_sock,
672
+ freq: hz_start,
673
+ demodulator_mode: demodulator_mode,
674
+ rds: rds,
675
+ bandwidth: bandwidth,
676
+ squelch: squelch,
677
+ suppress_details: true,
678
+ keep_alive: true
679
+ )
484
680
 
485
- in_signal = false
486
681
  candidate_signals = []
487
- strength_history = []
488
682
 
489
683
  # Adaptive peak finder – trims weakest ends after each pass
490
684
  # Converges quickly to the true center of the bell curve
491
685
  find_best_peak = lambda do |opts = {}|
492
- beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.raw_hz
493
- top_of_signal_hz = opts[:top_of_signal_hz].to_s.raw_hz
494
- end_of_signal_hz = top_of_signal_hz + step_hz
495
-
496
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
497
- # puts "Current Frequency: #{current_hz.pretty_hz}"
498
- puts "Signal Began: #{beg_of_signal_hz.pretty_hz}"
499
- puts "Signal Appeared to Peak at: #{top_of_signal_hz.pretty_hz}"
500
- puts "Calculated Signal End: #{end_of_signal_hz.pretty_hz}"
501
- # steps_between_beg_n_end = ((end_of_signal_hz - beg_of_signal_hz) / step_hz).abs
502
- # puts steps_between_beg_n_end.inspect
686
+ beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.cast_to_raw_hz
687
+ end_of_signal_hz = opts[:end_of_signal_hz].to_s.cast_to_raw_hz
503
688
 
504
689
  samples = []
505
- prev_best_sample = nil
690
+ prev_best_sample = {}
506
691
  consecutive_best = 0
507
692
  direction_up = true
508
693
 
@@ -525,19 +710,19 @@ module PWN
525
710
  print '>' if direction_up
526
711
  print '<' unless direction_up
527
712
  gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
528
- sleep lock_freq_duration
529
- strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
530
- strength_db = strength_db_float.round(1)
713
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
531
714
  samples.push({ hz: hz, strength_db: strength_db })
532
715
 
533
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
534
- # puts "Sampled Frequency: #{current_hz.pretty_hz} => Strength: #{strength_db} dBFS"
716
+ # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.cast_to_raw_hz
717
+ # puts "Sampled Frequency: #{current_hz.to_i.cast_to_pretty_hz} => Strength: #{strength_db} dBFS"
535
718
  end
536
719
 
537
720
  # Compute fresh averaged_samples from all cumulative samples
538
721
  averaged_samples = []
539
722
  samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
540
- avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
723
+ avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size)
724
+ # avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(2)
725
+ # avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
541
726
  averaged_samples.push({ hz: hz, strength_db: avg_strength })
542
727
  end
543
728
 
@@ -567,7 +752,7 @@ module PWN
567
752
  best_sample = averaged_samples.max_by { |s| s[:strength_db] }
568
753
 
569
754
  # Check for improvement
570
- if best_sample == prev_best_sample
755
+ if best_sample[:hz] == prev_best_sample[:hz]
571
756
  consecutive_best += 1
572
757
  else
573
758
  consecutive_best = 0
@@ -576,7 +761,7 @@ module PWN
576
761
  # Dup to avoid reference issues
577
762
  prev_best_sample = best_sample.dup
578
763
 
579
- puts "Pass #{pass_count}: Best #{best_sample[:hz].pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
764
+ puts "Pass #{pass_count}: Best #{best_sample[:hz].to_i.cast_to_pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
580
765
 
581
766
  # Break if no improvement in 3 consecutive passes or theres only one sample left
582
767
  break if consecutive_best.positive? || averaged_samples.size == 1
@@ -586,82 +771,101 @@ module PWN
586
771
  end
587
772
 
588
773
  # Begin scanning range
589
- puts "INFO: Scanning from #{hz_start.pretty_hz} to #{hz_target.pretty_hz} in steps of #{step.abs.pretty_hz} Hz.\nIf scans are slow and/or you're experiencing false positives/negatives, consider adjusting:\n1. The SDR's sample rate in GQRX\n\s\s- Click on `Configure I/O devices`.\n\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000).\n2. Adjust the :strength_lock parameter.\n3. Adjust the :lock_freq_duration parameter.\n4. Adjust the :precision parameter.\n5. Disable AI introspection in PWN::Env\nHappy scanning!\n\n"
774
+ puts "\n"
775
+ puts '-' * 86
776
+ 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."
777
+ puts "If scans are slow and/or you're experiencing false positives/negatives,"
778
+ puts 'consider adjusting the following:'
779
+ puts "1. The SDR's sample rate in GQRX"
780
+ puts "\s\s- Click on `Configure I/O devices`."
781
+ puts "\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000)."
782
+ puts '2. Adjust the :strength_lock parameter.'
783
+ puts '3. Adjust the :precision parameter.'
784
+ puts '4. Disable AI introspection in PWN::Env'
785
+ puts 'Happy scanning!'
786
+ puts '-' * 86
787
+ puts "\n\n\n"
590
788
 
591
789
  signals_arr = []
592
- # Format timestamp_start for filename
593
- hz_start.step(by: step, to: hz_target) do |hz|
790
+ hz = hz_start
791
+ while hz <= hz_target
594
792
  gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
595
- sleep lock_freq_duration
596
- strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
597
- strength_db = strength_db_float.round(1)
598
- prev_strength_db = strength_history.last || -Float::INFINITY
599
-
600
- if strength_db >= strength_lock && strength_db > prev_strength_db
601
- in_signal = true
602
- strength_history.push(strength_db)
603
- strength_history.shift if strength_history.size > 5
604
- current_strength = (strength_history.sum / strength_history.size).round(1)
605
-
606
- print '.'
607
- puts "#{hz.pretty_hz} => #{strength_db}"
608
-
609
- candidate = { hz: hz, freq: hz.pretty_hz, strength: current_strength }
610
- candidate_signals.push(candidate)
611
- else
612
- if in_signal
613
- beg_of_signal_hz = candidate_signals.map { |s| s[:hz] }.min
614
- # Previous max step_hz was actually the top of the signal
615
- top_of_signal_hz = candidate_signals.map { |s| s[:hz] }.max - step_hz
616
-
617
- skip_signal = false
618
- prev_freq = prev_freq_obj[:freq].to_s.raw_hz
619
- distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_freq).abs
620
- half_bandwidth = (bandwidth / 2).to_i
621
- skip_signal = true if distance_from_prev_detected_freq_hz < half_bandwidth && overlap_protection
622
- puts "Prev Dect Freq: #{prev_freq} | New Freq Edge: #{beg_of_signal_hz} | Distance from Prev Dect Freq: #{distance_from_prev_detected_freq_hz} Hz | Step Hz: #{step_hz} | Bandwidth: #{bandwidth} Hz | Half Bandwidth: #{half_bandwidth} Hz | Overlap Protection? #{overlap_protection} | Skip Signal? #{skip_signal}"
623
- next if skip_signal
624
-
625
- best_peak = find_best_peak.call(
626
- beg_of_signal_hz: beg_of_signal_hz,
627
- top_of_signal_hz: top_of_signal_hz
793
+ current_freq = 0
794
+ while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
795
+ current_freq = gqrx_cmd(
796
+ gqrx_sock: gqrx_sock,
797
+ cmd: 'f'
798
+ )
799
+ end
800
+
801
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
802
+
803
+ if strength_db >= strength_lock
804
+ puts '-' * 86
805
+ # Find left and right edges of the signal
806
+ candidate_signals = edge_detection(
807
+ gqrx_sock: gqrx_sock,
808
+ hz: hz,
809
+ step_hz: step_hz,
810
+ strength_lock: strength_lock
811
+ )
812
+ elsif candidate_signals.length.positive?
813
+ beg_of_signal_hz = candidate_signals.first[:hz]
814
+ top_of_signal_hz_idx = (candidate_signals.length - 1) / 2
815
+ top_of_signal_hz = candidate_signals[top_of_signal_hz_idx][:hz]
816
+ end_of_signal_hz = candidate_signals.last[:hz]
817
+ puts 'Candidate Signal(s) Detected:'
818
+ puts JSON.pretty_generate(candidate_signals)
819
+
820
+ prev_freq = prev_freq_obj[:freq].to_s.cast_to_raw_hz
821
+ distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_freq).abs
822
+ half_bandwidth = (bandwidth / 2).to_i
823
+
824
+ 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"
825
+
826
+ puts 'Finding Best Peak...'
827
+ best_peak = find_best_peak.call(
828
+ beg_of_signal_hz: beg_of_signal_hz,
829
+ end_of_signal_hz: end_of_signal_hz
830
+ )
831
+
832
+ if best_peak[:hz] && best_peak[:strength_db] > strength_lock
833
+ puts "\n**** Detected Signal ****"
834
+ best_freq = best_peak[:hz].to_i.cast_to_pretty_hz
835
+ best_strength_db = best_peak[:strength_db]
836
+ prev_freq_obj = init_freq(
837
+ gqrx_sock: gqrx_sock,
838
+ freq: best_freq,
839
+ rds: rds,
840
+ suppress_details: true,
841
+ keep_alive: true
842
+ )
843
+ prev_freq_obj[:strength_lock] = strength_lock
844
+ prev_freq_obj[:strength_db] = best_strength_db
845
+
846
+ 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."
847
+ ai_analysis = PWN::AI::Introspection.reflect_on(
848
+ request: prev_freq_obj.to_json,
849
+ system_role_content: system_role_content,
850
+ suppress_pii_warning: true
628
851
  )
629
852
 
630
- if best_peak[:hz] && best_peak[:strength_db] > strength_lock
631
- prev_freq_obj = init_freq(
632
- gqrx_sock: gqrx_sock,
633
- freq: best_peak[:hz],
634
- demodulator_mode: demodulator_mode,
635
- bandwidth: bandwidth,
636
- squelch: squelch,
637
- suppress_details: true,
638
- keep_alive: true
639
- )
640
- prev_freq_obj[:lock_freq_duration] = lock_freq_duration
641
- prev_freq_obj[:strength_lock] = strength_lock
642
-
643
- 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."
644
- ai_analysis = PWN::AI::Introspection.reflect_on(
645
- request: prev_freq_obj.to_json,
646
- system_role_content: system_role_content,
647
- suppress_pii_warning: true
648
- )
649
- prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
650
- puts "\n**** Detected Signal ****"
651
- puts JSON.pretty_generate(prev_freq_obj)
652
- signals_arr.push(prev_freq_obj)
653
- log_signals(
654
- signals_arr: signals_arr,
655
- timestamp_start: timestamp_start,
656
- scan_log: scan_log
657
- )
658
- end
659
- candidate_signals.clear
660
- sleep lock_freq_duration
853
+ prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
854
+ puts JSON.pretty_generate(prev_freq_obj)
855
+ puts '-' * 86
856
+ puts "\n\n\n"
857
+ signals_arr.push(prev_freq_obj)
858
+ log_signals(
859
+ signals_arr: signals_arr,
860
+ timestamp_start: timestamp_start,
861
+ scan_log: scan_log
862
+ )
863
+ hz = end_of_signal_hz
864
+ # gets
661
865
  end
662
- in_signal = false
663
- strength_history = []
866
+ candidate_signals.clear
664
867
  end
868
+ hz += step_hz
665
869
  end
666
870
 
667
871
  log_signals(
@@ -669,6 +873,8 @@ module PWN
669
873
  timestamp_start: timestamp_start,
670
874
  scan_log: scan_log
671
875
  )
876
+ rescue Interrupt
877
+ puts "\nCTRL+C detected - goodbye."
672
878
  rescue StandardError => e
673
879
  raise e
674
880
  ensure
@@ -693,9 +899,11 @@ module PWN
693
899
  )
694
900
 
695
901
  scan_resp[:signals].each do |signal|
696
- freq_obj = { gqrx_sock: gqrx_sock, keep_alive: true }
902
+ signal[:gqrx_sock] = gqrx_sock
903
+ # This is required to keep connection alive during analysis
904
+ signal[:keep_alive] = true
905
+ freq_obj = init_freq(signal)
697
906
  freq_obj = signal.merge(freq_obj)
698
- freq_obj = init_freq(freq_obj)
699
907
  # Redact gqrx_sock from output
700
908
  freq_obj.delete(:gqrx_sock)
701
909
  puts JSON.pretty_generate(freq_obj)
@@ -703,6 +911,8 @@ module PWN
703
911
  gets
704
912
  puts "\n" * 3
705
913
  end
914
+ rescue Interrupt
915
+ puts "\nCTRL+C detected - goodbye."
706
916
  rescue StandardError => e
707
917
  raise e
708
918
  ensure
@@ -741,7 +951,7 @@ module PWN
741
951
  public_class_method def self.disconnect(opts = {})
742
952
  gqrx_sock = opts[:gqrx_sock]
743
953
 
744
- PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock)
954
+ PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock) unless gqrx_sock.closed?
745
955
  rescue StandardError => e
746
956
  raise e
747
957
  end
@@ -763,16 +973,11 @@ module PWN
763
973
  port: 'optional - GQRX target port (defaults to 7356)'
764
974
  )
765
975
 
766
- gqrx_resp = #{self}.gqrx_cmd(
767
- gqrx_sock: 'required - GQRX socket object returned from #connect method',
768
- cmd: 'required - GQRX command to execute',
769
- resp_ok: 'optional - Expected response from GQRX to indicate success'
770
- )
771
-
772
976
  freq_obj = #{self}.init_freq(
773
977
  gqrx_sock: 'required - GQRX socket object returned from #connect method',
774
978
  freq: 'required - Frequency to set',
775
979
  demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
980
+ rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
776
981
  bandwidth: 'optional - Bandwidth (defaults to 200_000)',
777
982
  decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
778
983
  record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
@@ -786,9 +991,7 @@ module PWN
786
991
  target_freq: 'required - Target frequency',
787
992
  demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
788
993
  bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
789
- overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
790
994
  precision: 'optional - Precision (Defaults to 1)',
791
- lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
792
995
  strength_lock: 'optional - Strength lock (defaults to -70.0)',
793
996
  squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
794
997
  audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',